mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
feat(extension): compact recording overlay with live timer
Show the recording message for 10s, then switch to a fixed-width MM:SS timer with the icon-only finish button.
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 1.9 KiB |
@@ -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<HTMLDivElement>(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<HTMLDivElement>) => {
|
||||
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 (
|
||||
<>
|
||||
<style>{`
|
||||
@@ -35,78 +157,118 @@ export function RecordOverlay({ request }: Props) {
|
||||
`}</style>
|
||||
|
||||
<div
|
||||
ref={pillRef}
|
||||
data-slot="record-overlay-pill"
|
||||
data-phase={introVisible ? "intro" : "timer"}
|
||||
data-dragging={dragging ? "true" : "false"}
|
||||
onPointerDown={onPillPointerDown}
|
||||
style={{
|
||||
position: "fixed",
|
||||
bottom: 32,
|
||||
left: "50%",
|
||||
...(positioned
|
||||
? { top: dragPos.top, left: dragPos.left, bottom: "auto" }
|
||||
: { bottom: 32, left: "50%" }),
|
||||
zIndex: 2147483647,
|
||||
pointerEvents: "auto",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
gap: 5,
|
||||
backgroundColor: "#fff",
|
||||
borderRadius: 9999,
|
||||
padding: "10px 10px 10px 20px",
|
||||
boxShadow: "0 8px 32px rgba(15,23,42,0.16), 0 2px 8px rgba(0,0,0,0.1)",
|
||||
height: 50,
|
||||
boxSizing: "border-box",
|
||||
padding: "6px 8px 6px 16px",
|
||||
boxShadow: dragging
|
||||
? "0 12px 40px rgba(15,23,42,0.22), 0 2px 8px rgba(0,0,0,0.12)"
|
||||
: "0 8px 32px rgba(15,23,42,0.16), 0 2px 8px rgba(0,0,0,0.1)",
|
||||
opacity: show ? 1 : 0,
|
||||
transform: show ? "translateX(-50%) translateY(0)" : "translateX(-50%) translateY(8px)",
|
||||
transition: "opacity 300ms ease-out, transform 300ms ease-out",
|
||||
transform: positioned ? "none" : defaultTransform,
|
||||
transition: dragging
|
||||
? "box-shadow 150ms ease-out"
|
||||
: "opacity 300ms ease-out, transform 300ms ease-out, box-shadow 150ms ease-out",
|
||||
fontFamily:
|
||||
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
|
||||
maxWidth: "min(420px, calc(100vw - 32px))",
|
||||
cursor: dragging ? "grabbing" : "grab",
|
||||
userSelect: "none",
|
||||
touchAction: "none",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
data-slot="record-overlay-indicator"
|
||||
style={{
|
||||
width: 9,
|
||||
height: 9,
|
||||
borderRadius: "50%",
|
||||
backgroundColor: "#ef4444",
|
||||
flexShrink: 0,
|
||||
animation: "bsk-rec-pulse 1.4s ease-in-out infinite",
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
<div
|
||||
data-slot="record-overlay-status"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
minWidth: 0,
|
||||
flex: 1,
|
||||
fontSize: 16,
|
||||
fontWeight: 500,
|
||||
color: "#333",
|
||||
whiteSpace: "nowrap",
|
||||
userSelect: "none",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
borderRadius: 9999,
|
||||
outline: "none",
|
||||
minHeight: 32,
|
||||
paddingRight: 4,
|
||||
}}
|
||||
>
|
||||
{t("recordOverlay.recording")}
|
||||
</span>
|
||||
<span
|
||||
data-slot="record-overlay-indicator"
|
||||
style={{
|
||||
width: 9,
|
||||
height: 9,
|
||||
borderRadius: "50%",
|
||||
backgroundColor: "#ef4444",
|
||||
flexShrink: 0,
|
||||
animation: "bsk-rec-pulse 1.4s ease-in-out infinite",
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
data-slot="record-overlay-label"
|
||||
style={{
|
||||
display: "block",
|
||||
flex: "0 1 auto",
|
||||
fontSize: 16,
|
||||
fontWeight: 500,
|
||||
color: "#333",
|
||||
whiteSpace: "nowrap",
|
||||
userSelect: "none",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
width: introVisible ? "auto" : 48,
|
||||
maxWidth: introVisible ? 280 : 48,
|
||||
fontVariantNumeric: introVisible ? undefined : "tabular-nums",
|
||||
textAlign: introVisible ? "left" : "center",
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
minWidth: 0,
|
||||
transition: "max-width 180ms ease-out",
|
||||
}}
|
||||
>
|
||||
{introVisible ? t("recordOverlay.recording") : formatElapsed(elapsedSeconds)}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="record-overlay-finish"
|
||||
aria-label={t("recordOverlay.finish")}
|
||||
onClick={request.onFinish}
|
||||
style={{
|
||||
pointerEvents: "auto",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
display: "block",
|
||||
width: 32,
|
||||
height: 32,
|
||||
border: "none",
|
||||
borderRadius: 9999,
|
||||
padding: "8px 20px 8px 16px",
|
||||
fontSize: 15,
|
||||
fontWeight: 600,
|
||||
color: "#fff",
|
||||
backgroundColor: "#f97316",
|
||||
padding: 0,
|
||||
backgroundColor: "transparent",
|
||||
cursor: "pointer",
|
||||
transition: "background-color 150ms ease-out, opacity 150ms ease-out",
|
||||
whiteSpace: "nowrap",
|
||||
lineHeight: 1,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<RiStopCircleLine size={18} color="#fff" />
|
||||
{t("recordOverlay.finish")}
|
||||
<img
|
||||
src={recordIconUrl}
|
||||
alt=""
|
||||
width={24}
|
||||
height={24}
|
||||
draggable={false}
|
||||
style={{ display: "block", margin: "auto" }}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -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(<RecordOverlay request={{ id: "rec-1", onFinish }} />);
|
||||
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(<RecordOverlay request={{ id: "rec-1", onFinish: vi.fn() }} />);
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user