diff --git a/apps/extension/assets/record-icon.png b/apps/extension/assets/record-icon.png new file mode 100644 index 0000000..5a2c20a Binary files /dev/null and b/apps/extension/assets/record-icon.png differ diff --git a/apps/extension/src/content/RecordOverlay.tsx b/apps/extension/src/content/RecordOverlay.tsx index a083952..feba317 100644 --- a/apps/extension/src/content/RecordOverlay.tsx +++ b/apps/extension/src/content/RecordOverlay.tsx @@ -1,6 +1,12 @@ import { useTranslation } from "@browser-skill/i18n/react"; -import { RiStopCircleLine } from "@remixicon/react"; -import { useEffect, useState } from "react"; +import { + type PointerEvent as ReactPointerEvent, + useCallback, + useEffect, + useRef, + useState, +} from "react"; +import recordIconUrl from "../../assets/record-icon.png"; export interface RecordRequestData { id: string; @@ -11,9 +17,51 @@ type Props = { request: RecordRequestData | null; }; +const VIEWPORT_MARGIN = 16; +const DRAG_THRESHOLD_PX = 8; +const INTRO_DURATION_MS = 10_000; +const TIMER_REFRESH_MS = 250; + +function formatElapsed(seconds: number): string { + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + return `${String(minutes).padStart(2, "0")}:${String(remainingSeconds).padStart(2, "0")}`; +} + +function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} + +function clampDragPos( + top: number, + left: number, + panelW: number, + panelH: number, +): { top: number; left: number } { + const maxLeft = window.innerWidth - panelW - VIEWPORT_MARGIN; + const maxTop = window.innerHeight - panelH - VIEWPORT_MARGIN; + return { + top: clamp(top, VIEWPORT_MARGIN, Math.max(VIEWPORT_MARGIN, maxTop)), + left: clamp(left, VIEWPORT_MARGIN, Math.max(VIEWPORT_MARGIN, maxLeft)), + }; +} + export function RecordOverlay({ request }: Props) { const { t } = useTranslation("extension"); const [show, setShow] = useState(false); + const [elapsedSeconds, setElapsedSeconds] = useState(0); + const [dragPos, setDragPos] = useState<{ top: number; left: number } | null>(null); + const [dragging, setDragging] = useState(false); + const pillRef = useRef(null); + const dragStartRef = useRef<{ + pointerX: number; + pointerY: number; + originLeft: number; + originTop: number; + panelW: number; + panelH: number; + armed: boolean; + } | null>(null); useEffect(() => { if (request) { @@ -23,8 +71,82 @@ export function RecordOverlay({ request }: Props) { setShow(false); }, [request]); + useEffect(() => { + setElapsedSeconds(0); + setDragPos(null); + setDragging(false); + dragStartRef.current = null; + if (!request) return; + + const startedAt = Date.now(); + const updateElapsed = () => { + setElapsedSeconds(Math.floor((Date.now() - startedAt) / 1_000)); + }; + const timer = window.setInterval(updateElapsed, TIMER_REFRESH_MS); + return () => window.clearInterval(timer); + }, [request?.id]); + + const onPillPointerDown = useCallback((e: ReactPointerEvent) => { + if (e.button !== 0) return; + if (e.target instanceof Element && e.target.closest("button, a, input, textarea, select")) { + return; + } + const pill = pillRef.current; + if (!pill) return; + const rect = pill.getBoundingClientRect(); + dragStartRef.current = { + pointerX: e.clientX, + pointerY: e.clientY, + originLeft: rect.left, + originTop: rect.top, + panelW: pill.offsetWidth || rect.width, + panelH: pill.offsetHeight || rect.height, + armed: false, + }; + }, []); + + useEffect(() => { + const onMove = (e: PointerEvent) => { + const start = dragStartRef.current; + if (!start) return; + const dx = e.clientX - start.pointerX; + const dy = e.clientY - start.pointerY; + if (!start.armed) { + if (Math.hypot(dx, dy) < DRAG_THRESHOLD_PX) return; + start.armed = true; + setDragging(true); + setDragPos({ top: start.originTop, left: start.originLeft }); + } + setDragPos( + clampDragPos(start.originTop + dy, start.originLeft + dx, start.panelW, start.panelH), + ); + }; + const onUp = () => { + const start = dragStartRef.current; + if (!start) return; + if (start.armed) { + setDragging(false); + } + dragStartRef.current = null; + }; + window.addEventListener("pointermove", onMove); + window.addEventListener("pointerup", onUp); + window.addEventListener("pointercancel", onUp); + return () => { + window.removeEventListener("pointermove", onMove); + window.removeEventListener("pointerup", onUp); + window.removeEventListener("pointercancel", onUp); + }; + }, []); + if (!request) return null; + const introVisible = elapsedSeconds * 1_000 < INTRO_DURATION_MS; + const positioned = dragPos !== null; + const defaultTransform = show + ? "translateX(-50%) translateY(0)" + : "translateX(-50%) translateY(8px)"; + return ( <>
- - - {t("recordOverlay.recording")} - + + + {introVisible ? t("recordOverlay.recording") : formatElapsed(elapsedSeconds)} + +
diff --git a/apps/extension/src/content/__tests__/RecordOverlay.test.tsx b/apps/extension/src/content/__tests__/RecordOverlay.test.tsx index 9ed5e5a..7b8299c 100644 --- a/apps/extension/src/content/__tests__/RecordOverlay.test.tsx +++ b/apps/extension/src/content/__tests__/RecordOverlay.test.tsx @@ -1,37 +1,167 @@ -import { cleanup, fireEvent, render } from "@testing-library/react"; +import { i18n } from "@browser-skill/i18n"; +import { I18nextProvider } from "@browser-skill/i18n/react"; +import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { createElement } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { RecordOverlay } from "../RecordOverlay"; +function renderOverlay(request: { id: string; onFinish: () => void } | null) { + return render( + createElement(I18nextProvider, { i18n }, createElement(RecordOverlay, { request })), + ); +} + +function advanceRecording(ms: number) { + act(() => { + vi.advanceTimersByTime(ms); + }); +} + describe("RecordOverlay", () => { afterEach(() => { cleanup(); + vi.useRealTimers(); }); - it("renders the bottom pill with orange finish CTA and no logo", () => { + it("renders the bottom pill with the icon-only finish button", () => { const onFinish = vi.fn(); - const { container } = render(); + const { container } = renderOverlay({ id: "rec-1", onFinish }); const pill = container.querySelector("[data-slot='record-overlay-pill']"); expect(pill).toBeTruthy(); - expect(pill?.querySelector("img")).toBeNull(); expect((pill as HTMLElement).style.borderRadius).toBe("9999px"); expect((pill as HTMLElement).style.backgroundColor).toBe("#fff"); + expect((pill as HTMLElement).style.gap).toBe("5px"); + expect((pill as HTMLElement).style.padding).toBe("6px 8px 6px 16px"); + expect((pill as HTMLElement).style.height).toBe("50px"); + expect((pill as HTMLElement).style.boxSizing).toBe("border-box"); + expect(container.querySelector("[data-slot='record-overlay-collapse']")).toBeNull(); - const finish = container.querySelector("[data-slot='record-overlay-finish']"); + const status = container.querySelector("[data-slot='record-overlay-status']") as HTMLElement; + expect(status.style.minHeight).toBe("32px"); + const finish = screen.getByRole("button", { + name: i18n.t("recordOverlay.finish", { ns: "extension" }), + }); expect(finish).toBeTruthy(); - expect((finish as HTMLElement).style.backgroundColor).toBe("#f97316"); + expect(finish.textContent).toBe(""); + const icon = finish.querySelector("img"); + expect(icon).toBeTruthy(); + expect(icon?.getAttribute("src")).toContain("record-icon.png"); + expect((finish as HTMLElement).style.width).toBe("32px"); + expect((finish as HTMLElement).style.height).toBe("32px"); + expect(icon?.getAttribute("width")).toBe("24"); + expect(icon?.getAttribute("height")).toBe("24"); expect((finish as HTMLElement).style.borderRadius).toBe("9999px"); - fireEvent.click(finish!); + fireEvent.click(finish); expect(onFinish).toHaveBeenCalledTimes(1); }); it("shows a pulsing recording indicator and no full-screen glow layer", () => { - const { container } = render(); + const { container } = renderOverlay({ id: "rec-1", onFinish: vi.fn() }); expect(container.querySelector("[data-slot='record-overlay']")).toBeNull(); const indicator = container.querySelector("[data-slot='record-overlay-indicator']"); expect(indicator).toBeTruthy(); expect((indicator as HTMLElement).style.animation).toContain("bsk-rec-pulse"); }); + + it("shows the recording message for ten seconds, then displays elapsed time", () => { + vi.useFakeTimers(); + const { container } = renderOverlay({ id: "rec-1", onFinish: vi.fn() }); + const pill = container.querySelector("[data-slot='record-overlay-pill']") as HTMLElement; + const label = container.querySelector("[data-slot='record-overlay-label']") as HTMLElement; + + expect(pill.dataset.phase).toBe("intro"); + expect(label.textContent).toBe(i18n.t("recordOverlay.recording", { ns: "extension" })); + + advanceRecording(9999); + expect(pill.dataset.phase).toBe("intro"); + + advanceRecording(1); + expect(pill.dataset.phase).toBe("timer"); + expect(label.textContent).toBe("00:10"); + }); + + it("formats elapsed time as zero-padded minutes and seconds", () => { + vi.useFakeTimers(); + const { container } = renderOverlay({ id: "rec-1", onFinish: vi.fn() }); + const label = container.querySelector("[data-slot='record-overlay-label']") as HTMLElement; + + advanceRecording(125_000); + expect(label.textContent).toBe("02:05"); + }); + + it("keeps a fixed timer label width with tabular numerals", () => { + vi.useFakeTimers(); + const { container } = renderOverlay({ id: "rec-1", onFinish: vi.fn() }); + const label = container.querySelector("[data-slot='record-overlay-label']") as HTMLElement; + + advanceRecording(10_000); + expect(label.style.width).toBe("48px"); + expect(label.style.maxWidth).toBe("48px"); + expect(label.style.fontVariantNumeric).toBe("tabular-nums"); + expect(label.style.textAlign).toBe("center"); + + advanceRecording(1_000); + expect(label.textContent).toBe("00:11"); + expect(label.style.width).toBe("48px"); + }); + + it("resets the intro and elapsed time for a new request", () => { + vi.useFakeTimers(); + const first = { id: "rec-1", onFinish: vi.fn() }; + const second = { id: "rec-2", onFinish: vi.fn() }; + const { container, rerender } = renderOverlay(first); + + advanceRecording(65_000); + rerender( + createElement(I18nextProvider, { i18n }, createElement(RecordOverlay, { request: second })), + ); + + const pill = container.querySelector("[data-slot='record-overlay-pill']") as HTMLElement; + const label = container.querySelector("[data-slot='record-overlay-label']") as HTMLElement; + expect(pill.dataset.phase).toBe("intro"); + expect(label.textContent).toBe(i18n.t("recordOverlay.recording", { ns: "extension" })); + + advanceRecording(10_000); + expect(pill.dataset.phase).toBe("timer"); + expect(label.textContent).toBe("00:10"); + }); + + it("does not start drag when pointer down on finish", () => { + const { container } = renderOverlay({ id: "rec-1", onFinish: vi.fn() }); + const pill = container.querySelector("[data-slot='record-overlay-pill']") as HTMLElement; + const finish = container.querySelector("[data-slot='record-overlay-finish']") as HTMLElement; + + fireEvent.pointerDown(finish, { button: 0, clientX: 10, clientY: 10, pointerId: 1 }); + expect(pill.getAttribute("data-dragging")).toBe("false"); + }); + + it("moves the pill when dragging the non-button area without toggling collapse", () => { + const { container } = renderOverlay({ id: "rec-1", onFinish: vi.fn() }); + const pill = container.querySelector("[data-slot='record-overlay-pill']") as HTMLElement; + + pill.getBoundingClientRect = () => + ({ + top: 600, + left: 200, + width: 320, + height: 48, + right: 520, + bottom: 648, + }) as DOMRect; + Object.defineProperty(pill, "offsetWidth", { value: 320, configurable: true }); + Object.defineProperty(pill, "offsetHeight", { value: 48, configurable: true }); + + fireEvent.pointerDown(pill, { button: 0, clientX: 300, clientY: 620, pointerId: 1 }); + fireEvent(window, new PointerEvent("pointermove", { clientX: 360, clientY: 580 })); + fireEvent(window, new PointerEvent("pointerup", { pointerId: 1 })); + + expect(pill.getAttribute("data-phase")).toBe("intro"); + expect(pill.style.left).toBe("260px"); + expect(pill.style.top).toBe("560px"); + expect(pill.style.bottom).toBe("auto"); + expect(pill.style.transform).toBe("none"); + }); }); diff --git a/packages/i18n/src/locales/zh-CN/extension.json b/packages/i18n/src/locales/zh-CN/extension.json index 7f6f509..466e8f5 100644 --- a/packages/i18n/src/locales/zh-CN/extension.json +++ b/packages/i18n/src/locales/zh-CN/extension.json @@ -85,7 +85,7 @@ "notificationTitle": "BrowserSkill:Agent 需要你的帮助" }, "recordOverlay": { - "recording": "正在录制用户操作", + "recording": "正在录制你的操作", "finish": "结束" } }