diff --git a/packages/react-grab/e2e/screenshot-labels.spec.ts b/packages/react-grab/e2e/screenshot-labels.spec.ts new file mode 100644 index 000000000..9c4bae513 --- /dev/null +++ b/packages/react-grab/e2e/screenshot-labels.spec.ts @@ -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 => + 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 => { + 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"); + }); +}); diff --git a/packages/react-grab/src/components/overlay-canvas.tsx b/packages/react-grab/src/components/overlay-canvas.tsx index b505003bc..9c76e421d 100644 --- a/packages/react-grab/src/components/overlay-canvas.tsx +++ b/packages/react-grab/src/components/overlay-canvas.tsx @@ -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, @@ -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"; @@ -72,6 +83,8 @@ interface OverlayCanvasProps { }>; labelInstances?: SelectionLabelInstance[]; + + screenshotLabels?: ScreenshotLabel[]; } export const OverlayCanvas: Component = (props) => { @@ -288,6 +301,86 @@ export const OverlayCanvas: Component = (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; @@ -306,6 +399,8 @@ export const OverlayCanvas: Component = (props) => { mainContext.drawImage(layer.canvas, 0, 0, canvasWidth, canvasHeight); } } + + renderScreenshotLabels(); }; const interpolateBounds = ( @@ -522,6 +617,15 @@ export const OverlayCanvas: Component = (props) => { ), ); + createEffect( + on( + () => props.screenshotLabels, + () => { + scheduleAnimationFrame(); + }, + ), + ); + createEffect( on( () => [props.grabbedBoxes, props.labelInstances] as const, diff --git a/packages/react-grab/src/components/renderer.tsx b/packages/react-grab/src/components/renderer.tsx index 6ab15c16e..5ee318925 100644 --- a/packages/react-grab/src/components/renderer.tsx +++ b/packages/react-grab/src/components/renderer.tsx @@ -27,6 +27,7 @@ export const ReactGrabRenderer: Component = (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 diff --git a/packages/react-grab/src/constants.ts b/packages/react-grab/src/constants.ts index 24e0f8a35..be0e64aba 100644 --- a/packages/react-grab/src/constants.ts +++ b/packages/react-grab/src/constants.ts @@ -187,6 +187,21 @@ export const DROPDOWN_EDGE_TRANSFORM_ORIGIN = { export const NEXTJS_REVALIDATION_DELAY_MS = 1000; +export const SCREENSHOT_LABEL_FONT_SIZE_PX = 11; +export const SCREENSHOT_LABEL_PADDING_X_PX = 4; +export const SCREENSHOT_LABEL_PADDING_Y_PX = 2; +export const SCREENSHOT_LABEL_MIN_ELEMENT_WIDTH_PX = 80; +export const SCREENSHOT_LABEL_MIN_ELEMENT_HEIGHT_PX = 28; +export const SCREENSHOT_LABEL_COLLISION_PADDING_PX = 2; +export const SCREENSHOT_LABEL_BACKGROUND_COLOR = "rgba(0, 0, 0, 0.78)"; +export const SCREENSHOT_LABEL_TEXT_COLOR = "#ffffff"; +export const SCREENSHOT_LABEL_SECONDARY_COLOR = "rgba(255, 255, 255, 0.6)"; +export const SCREENSHOT_LABEL_CORNER_RADIUS_PX = 3; +export const SCREENSHOT_LABEL_MIDDOT = "\u00B7"; +export const SCREENSHOT_LABEL_FONT_FAMILY = + "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace"; +export const SCREENSHOT_LABEL_VERTICAL_OFFSET_PX = 2; + export const TEXTAREA_MAX_HEIGHT_PX = 95; export const IME_COMPOSING_KEY_CODE = 229; diff --git a/packages/react-grab/src/core/index.tsx b/packages/react-grab/src/core/index.tsx index 31be2169e..f4bf9acfd 100644 --- a/packages/react-grab/src/core/index.tsx +++ b/packages/react-grab/src/core/index.tsx @@ -78,6 +78,8 @@ import { WINDOW_REFOCUS_GRACE_PERIOD_MS, PREVIEW_TEXT_MAX_LENGTH, NEXTJS_REVALIDATION_DELAY_MS, + SCREENSHOT_LABEL_MIN_ELEMENT_HEIGHT_PX, + SCREENSHOT_LABEL_MIN_ELEMENT_WIDTH_PX, TOOLBAR_DEFAULT_POSITION_RATIO, DEFAULT_ACTION_ID, } from "../constants.js"; @@ -97,6 +99,7 @@ import type { GrabbedBox, ReactGrabAPI, ReactGrabState, + ScreenshotLabel, SelectionLabelInstance, ContextMenuActionContext, ContextMenuAction, @@ -136,6 +139,15 @@ import { copyContent } from "../utils/copy-content.js"; import { generateId } from "../utils/generate-id.js"; import { logRecoverableError } from "../utils/log-recoverable-error.js"; import { getNearestEdge } from "../utils/get-nearest-edge.js"; +import { + collectScreenshotLabels, + resolveScreenshotLabelFileName, + type ScreenshotLabelCandidate, +} from "../utils/collect-screenshot-labels.js"; +import { + isScreenshotShortcutPressed, + isScreenshotShortcutReleased, +} from "../utils/is-screenshot-shortcut.js"; const builtInPlugins = [copyPlugin, commentPlugin, openPlugin]; @@ -3469,6 +3481,125 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { }, 0); }; + const [screenshotLabelCandidates, setScreenshotLabelCandidates] = createSignal< + ScreenshotLabelCandidate[] + >([]); + const [screenshotLabelFileNames, setScreenshotLabelFileNames] = createSignal< + Map + >(new Map()); + let screenshotLabelResolutionToken = 0; + + const clearScreenshotLabels = () => { + screenshotLabelResolutionToken++; + setScreenshotLabelCandidates([]); + setScreenshotLabelFileNames(new Map()); + }; + + const activateScreenshotLabels = () => { + const candidates = collectScreenshotLabels(); + if (candidates.length === 0) { + clearScreenshotLabels(); + return; + } + + const initialFileNames = new Map(); + for (const candidate of candidates) { + if (candidate.fileBaseName) { + initialFileNames.set(candidate.fiberId, candidate.fileBaseName); + } + } + + const resolutionToken = ++screenshotLabelResolutionToken; + setScreenshotLabelFileNames(initialFileNames); + setScreenshotLabelCandidates(candidates); + + for (const candidate of candidates) { + if (initialFileNames.has(candidate.fiberId)) continue; + void resolveScreenshotLabelFileName(candidate.element).then((fileBaseName) => { + if (resolutionToken !== screenshotLabelResolutionToken) return; + if (!fileBaseName) return; + setScreenshotLabelFileNames((previous) => { + if (previous.get(candidate.fiberId) === fileBaseName) return previous; + const next = new Map(previous); + next.set(candidate.fiberId, fileBaseName); + return next; + }); + }); + } + }; + + const computedScreenshotLabels = createMemo(() => { + const candidates = screenshotLabelCandidates(); + if (candidates.length === 0) return []; + void viewportVersion(); + const fileNames = screenshotLabelFileNames(); + const labels: ScreenshotLabel[] = []; + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + for (const candidate of candidates) { + if (!isElementConnected(candidate.element)) continue; + const rect = candidate.element.getBoundingClientRect(); + if (rect.width < SCREENSHOT_LABEL_MIN_ELEMENT_WIDTH_PX) continue; + if (rect.height < SCREENSHOT_LABEL_MIN_ELEMENT_HEIGHT_PX) continue; + if (rect.right <= 0 || rect.bottom <= 0) continue; + if (rect.left >= viewportWidth || rect.top >= viewportHeight) continue; + + labels.push({ + id: candidate.fiberId, + x: rect.left, + y: rect.top, + area: rect.width * rect.height, + componentName: candidate.componentName, + fileBaseName: fileNames.get(candidate.fiberId) ?? candidate.fileBaseName, + }); + } + + return labels; + }); + + const isScreenshotTriggerKey = (event: KeyboardEvent): boolean => { + return ( + event.key === "Meta" || + event.key === "Shift" || + event.key === "PrintScreen" || + event.code === "PrintScreen" + ); + }; + + const handleScreenshotKeydown = (event: KeyboardEvent): void => { + if (event.repeat) return; + if (!isScreenshotTriggerKey(event)) return; + if (!isScreenshotShortcutPressed(event)) return; + if (!isEnabled()) return; + if (isPromptMode()) return; + if (isCopying()) return; + if (isKeyboardEventTriggeredByInput(event)) return; + if (screenshotLabelCandidates().length > 0) return; + activateScreenshotLabels(); + }; + + const handleScreenshotKeyup = (event: KeyboardEvent): void => { + if (screenshotLabelCandidates().length === 0) return; + if (!isScreenshotShortcutReleased(event)) return; + clearScreenshotLabels(); + }; + + // Listen on both window and document with capture so the activation + // still fires when an upstream listener calls stopPropagation on the + // other target. Same handler on both - the early-return on existing + // candidates makes a second invocation for the same press a no-op. + eventListenerManager.addWindowListener("keydown", handleScreenshotKeydown, { capture: true }); + eventListenerManager.addDocumentListener("keydown", handleScreenshotKeydown, { capture: true }); + eventListenerManager.addWindowListener("keyup", handleScreenshotKeyup, { capture: true }); + eventListenerManager.addDocumentListener("keyup", handleScreenshotKeyup, { capture: true }); + + eventListenerManager.addWindowListener("blur", () => { + if (screenshotLabelCandidates().length > 0) { + clearScreenshotLabels(); + } + }); + createEffect(() => { const hue = pluginRegistry.store.theme.hue; if (hue !== 0) { @@ -3506,6 +3637,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { selectionArrowNavigationState={arrowNavigationState()} onArrowNavigationSelect={handleArrowNavigationSelect} labelInstances={computedLabelInstances()} + screenshotLabels={computedScreenshotLabels()} dragVisible={dragVisible()} dragBounds={dragBounds()} grabbedBoxes={computedGrabbedBoxes()} diff --git a/packages/react-grab/src/types.ts b/packages/react-grab/src/types.ts index affbf06bb..26ee9a34d 100644 --- a/packages/react-grab/src/types.ts +++ b/packages/react-grab/src/types.ts @@ -331,6 +331,15 @@ export interface SelectionLabelInstance { hideArrow?: boolean; } +export interface ScreenshotLabel { + id: number; + x: number; + y: number; + area: number; + componentName: string; + fileBaseName: string | null; +} + export interface FrozenLabelEntry { tagName: string; componentName?: string; @@ -355,6 +364,7 @@ export interface ReactGrabRendererProps { selectionArrowNavigationState?: ArrowNavigationState; onArrowNavigationSelect?: (index: number) => void; labelInstances?: SelectionLabelInstance[]; + screenshotLabels?: ScreenshotLabel[]; dragVisible?: boolean; dragBounds?: OverlayBounds; grabbedBoxes?: Array<{ diff --git a/packages/react-grab/src/utils/collect-screenshot-labels.ts b/packages/react-grab/src/utils/collect-screenshot-labels.ts new file mode 100644 index 000000000..5b13ca595 --- /dev/null +++ b/packages/react-grab/src/utils/collect-screenshot-labels.ts @@ -0,0 +1,154 @@ +import { + _fiberRoots, + getDisplayName, + getFiberFromHostInstance, + isCompositeFiber, + type Fiber, + type FiberRoot, +} from "bippy"; +import { resolveSource } from "../core/context.js"; +import { isUsefulComponentName } from "./is-useful-component-name.js"; +import { normalizeFilePath } from "./normalize-file-path.js"; + +interface FiberRootLike extends FiberRoot { + current: Fiber | null; +} + +interface FiberDebugLink { + fileName?: string; + lineNumber?: number; + columnNumber?: number; +} + +const collectFiberRoots = (): Set => { + const typedFiberRoots = _fiberRoots as Set; + if (typedFiberRoots.size > 0) return typedFiberRoots; + + const collected = new Set(); + if (typeof document === "undefined") return collected; + + const elementsToVisit: Element[] = [document.body]; + while (elementsToVisit.length > 0) { + const element = elementsToVisit.pop(); + if (!element) continue; + + const fiber = getFiberFromHostInstance(element); + if (fiber) { + let current: Fiber | null = fiber; + while (current?.return) current = current.return; + const root = current?.stateNode as FiberRootLike | undefined; + if (root) collected.add(root); + continue; + } + for (const child of element.children) { + elementsToVisit.push(child); + } + } + + return collected; +}; + +const getSyncFileName = (fiber: Fiber): string | null => { + let current: Fiber | null = fiber; + while (current) { + const debugSource = current._debugSource as FiberDebugLink | null | undefined; + if (debugSource?.fileName) { + return normalizeFilePath(debugSource.fileName); + } + current = (current._debugOwner ?? null) as Fiber | null; + } + return null; +}; + +const getFileBaseName = (filePath: string): string => { + const cleaned = filePath.split("?")[0].split("#")[0].replace(/\\/g, "/"); + const parts = cleaned.split("/"); + return parts[parts.length - 1] || cleaned; +}; + +const isElementOnscreen = (element: Element): boolean => { + if (!(element instanceof Element)) return false; + const rect = element.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) return false; + if (rect.right <= 0 || rect.bottom <= 0) return false; + if (rect.left >= window.innerWidth || rect.top >= window.innerHeight) return false; + return true; +}; + +export interface ScreenshotLabelCandidate { + fiberId: number; + element: Element; + componentName: string; + fileBaseName: string | null; +} + +let fiberIdSequence = 0; +const fiberIdCache = new WeakMap(); +const getStableFiberId = (fiber: Fiber): number => { + const cached = fiberIdCache.get(fiber); + if (cached !== undefined) return cached; + const id = ++fiberIdSequence; + fiberIdCache.set(fiber, id); + return id; +}; + +const findNearestHostElement = (fiber: Fiber): Element | null => { + const queue: Array = [fiber.child]; + while (queue.length > 0) { + const candidate = queue.shift(); + if (!candidate) continue; + const stateNode = candidate.stateNode; + if (stateNode instanceof Element) return stateNode; + if (candidate.child) queue.push(candidate.child); + if (candidate.sibling) queue.push(candidate.sibling); + } + return null; +}; + +export const collectScreenshotLabels = (): ScreenshotLabelCandidate[] => { + const fiberRoots = collectFiberRoots(); + const candidates: ScreenshotLabelCandidate[] = []; + const seenElements = new WeakSet(); + const walkStack: Array = []; + + for (const fiberRoot of fiberRoots) { + if (!fiberRoot.current) continue; + walkStack.push(fiberRoot.current); + } + + while (walkStack.length > 0) { + const fiber = walkStack.pop(); + if (!fiber) continue; + + if (fiber.sibling) walkStack.push(fiber.sibling); + if (fiber.child) walkStack.push(fiber.child); + + if (!isCompositeFiber(fiber)) continue; + const displayName = getDisplayName(fiber.type); + if (!displayName || !isUsefulComponentName(displayName)) continue; + + const hostElement = findNearestHostElement(fiber); + if (!hostElement || seenElements.has(hostElement) || !isElementOnscreen(hostElement)) continue; + + seenElements.add(hostElement); + const fileName = getSyncFileName(fiber); + candidates.push({ + fiberId: getStableFiberId(fiber), + element: hostElement, + componentName: displayName, + fileBaseName: fileName ? getFileBaseName(fileName) : null, + }); + } + + return candidates; +}; + +export const resolveScreenshotLabelFileName = async (element: Element): Promise => { + try { + const source = await resolveSource(element); + if (!source?.filePath) return null; + return getFileBaseName(normalizeFilePath(source.filePath)); + } catch { + return null; + } +}; diff --git a/packages/react-grab/src/utils/is-linux.ts b/packages/react-grab/src/utils/is-linux.ts new file mode 100644 index 000000000..ab541ace4 --- /dev/null +++ b/packages/react-grab/src/utils/is-linux.ts @@ -0,0 +1,29 @@ +let cachedIsLinux: boolean | null = null; + +const getPlatformFromUserAgentData = (): string | null => { + if (typeof navigator === "undefined") return null; + if (!("userAgentData" in navigator)) return null; + + const userAgentData = navigator.userAgentData; + if (typeof userAgentData !== "object" || userAgentData === null) return null; + if (!("platform" in userAgentData)) return null; + + const platform = userAgentData.platform; + if (typeof platform !== "string") return null; + return platform; +}; + +export const isLinux = (): boolean => { + if (cachedIsLinux === null) { + if (typeof navigator === "undefined") { + cachedIsLinux = false; + return cachedIsLinux; + } + + const platform = navigator.platform ?? getPlatformFromUserAgentData() ?? navigator.userAgent; + const userAgent = navigator.userAgent ?? ""; + const isAndroid = /Android/i.test(platform) || /Android/i.test(userAgent); + cachedIsLinux = /Linux|X11|CrOS/i.test(platform) && !isAndroid; + } + return cachedIsLinux; +}; diff --git a/packages/react-grab/src/utils/is-screenshot-shortcut.ts b/packages/react-grab/src/utils/is-screenshot-shortcut.ts new file mode 100644 index 000000000..25df56045 --- /dev/null +++ b/packages/react-grab/src/utils/is-screenshot-shortcut.ts @@ -0,0 +1,19 @@ +import { isLinux } from "./is-linux.js"; +import { isMac } from "./is-mac.js"; + +export const isScreenshotShortcutPressed = (event: KeyboardEvent): boolean => { + if (isLinux()) { + return event.key === "PrintScreen" || event.code === "PrintScreen"; + } + if (isMac()) { + return event.metaKey && event.shiftKey; + } + return event.metaKey && event.shiftKey; +}; + +export const isScreenshotShortcutReleased = (event: KeyboardEvent): boolean => { + if (isLinux()) { + return event.key === "PrintScreen" || event.code === "PrintScreen"; + } + return event.key === "Meta" || event.key === "Shift"; +};