rrweb extension implementation (#1044)
* feat: add rrweb web-extension package * refactor: make the extension suitable for manifest v3 * update tsconfig.json * use version_name rather than recorder_version in manifest.json * update manifest.json * enable to keep recording after changing tabs * enable to record between tabs and urls * fix CI error * try to fix CI error * feat: add pause and resume buttons * feat: add a link to new session after recording * improve session list * refactor: migrate session storage from chrome local storage to indexedDB * feat: add pagination to session list * fix: multiple recorders are started after pausing and resuming process * fix: can't stop recording on firefox browser * update type import of 'eventWithTime' * fix CI error * doc: add readme * Apply suggestions from Justin's code review Co-authored-by: Justin Halsall <Juice10@users.noreply.github.com> * refactor: make use of webNavigation API to implement recording consistent during page navigation * fix firefox compatibility issue and add title to pages * add mouseleave listener to enhance the recording liability * fix firefox compatibility issue and improve the experience of recording resume after closing tabs * update tsconfig * upgrade vite-plugin-web-extension config to fix some bugs on facebook web page * update import links * refactor: cross tab recording mechanism apply Justin's suggestion * refactor: slipt util/index.ts into multiple files * implement cross-origin iframe recording * fix: regression of issue: ShadowHost can't be a string (issue 941) * refactor shadow dom recording to make tests cover key code * Apply formatting changes * increase the node memory limitation to avoid CI failure * Create lovely-pears-cross.md * Apply formatting changes * Update packages/web-extension/package.json * Update .changeset/lovely-pears-cross.md * update change logs * delete duplicated property --------- Co-authored-by: Justin Halsall <Juice10@users.noreply.github.com>
This commit is contained in:
37
packages/web-extension/src/pages/App.tsx
Normal file
37
packages/web-extension/src/pages/App.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Route, Routes } from 'react-router-dom';
|
||||
import SidebarWithHeader from '~/components/SidebarWithHeader';
|
||||
import { SessionList } from './SessionList';
|
||||
import { FiList, FiSettings } from 'react-icons/fi';
|
||||
import Player from './Player';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<SidebarWithHeader
|
||||
title="Sessions"
|
||||
headBarItems={[
|
||||
{
|
||||
label: 'Settings',
|
||||
icon: FiSettings,
|
||||
href: '/options/index.html#',
|
||||
},
|
||||
{
|
||||
label: 'Sessions',
|
||||
icon: FiList,
|
||||
href: '#',
|
||||
},
|
||||
]}
|
||||
sideBarItems={[
|
||||
{
|
||||
label: 'List',
|
||||
icon: FiList,
|
||||
href: `#`,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Routes>
|
||||
<Route path="/" element={<SessionList />} />
|
||||
<Route path="session/:sessionId" element={<Player />} />
|
||||
</Routes>
|
||||
</SidebarWithHeader>
|
||||
);
|
||||
}
|
||||
68
packages/web-extension/src/pages/Player.tsx
Normal file
68
packages/web-extension/src/pages/Player.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { useRef, useEffect, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import Replayer from 'rrweb-player';
|
||||
import {
|
||||
Box,
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
Center,
|
||||
} from '@chakra-ui/react';
|
||||
import { getEvents, getSession } from '~/utils/storage';
|
||||
|
||||
export default function Player() {
|
||||
const playerElRef = useRef<HTMLDivElement>(null);
|
||||
const playerRef = useRef<Replayer | null>(null);
|
||||
const { sessionId } = useParams();
|
||||
const [sessionName, setSessionName] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId) return;
|
||||
getSession(sessionId)
|
||||
.then((session) => {
|
||||
setSessionName(session.name);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
});
|
||||
getEvents(sessionId)
|
||||
.then((events) => {
|
||||
if (!playerElRef.current || !sessionId) return;
|
||||
|
||||
const linkEl = document.createElement('link');
|
||||
linkEl.href =
|
||||
'https://cdn.jsdelivr.net/npm/rrweb-player@latest/dist/style.css';
|
||||
linkEl.rel = 'stylesheet';
|
||||
document.head.appendChild(linkEl);
|
||||
playerRef.current = new Replayer({
|
||||
target: playerElRef.current as HTMLElement,
|
||||
props: {
|
||||
events,
|
||||
autoPlay: true,
|
||||
},
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
});
|
||||
return () => {
|
||||
playerRef.current?.pause();
|
||||
};
|
||||
}, [sessionId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Breadcrumb mb={5} fontSize="md">
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href="#">Sessions</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink>{sessionName}</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
</Breadcrumb>
|
||||
<Center>
|
||||
<Box ref={playerElRef}></Box>
|
||||
</Center>
|
||||
</>
|
||||
);
|
||||
}
|
||||
318
packages/web-extension/src/pages/SessionList.tsx
Normal file
318
packages/web-extension/src/pages/SessionList.tsx
Normal file
@@ -0,0 +1,318 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
chakra,
|
||||
Table,
|
||||
Thead,
|
||||
Tbody,
|
||||
Tr,
|
||||
Th,
|
||||
Td,
|
||||
Text,
|
||||
TableContainer,
|
||||
Flex,
|
||||
Checkbox,
|
||||
Button,
|
||||
Spacer,
|
||||
IconButton,
|
||||
Select,
|
||||
Input,
|
||||
Divider,
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
createColumnHelper,
|
||||
useReactTable,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
SortingState,
|
||||
getSortedRowModel,
|
||||
PaginationState,
|
||||
} from '@tanstack/react-table';
|
||||
import { VscTriangleDown, VscTriangleUp } from 'react-icons/vsc';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Session, EventName } from '~/types';
|
||||
import Channel from '~/utils/channel';
|
||||
import { deleteSessions, getAllSessions } from '~/utils/storage';
|
||||
import {
|
||||
FiChevronLeft,
|
||||
FiChevronRight,
|
||||
FiChevronsLeft,
|
||||
FiChevronsRight,
|
||||
} from 'react-icons/fi';
|
||||
|
||||
const columnHelper = createColumnHelper<Session>();
|
||||
const channel = new Channel();
|
||||
|
||||
export function SessionList() {
|
||||
const [sessions, setSessions] = useState<Session[]>([]);
|
||||
const navigate = useNavigate();
|
||||
const [sorting, setSorting] = useState<SortingState>([
|
||||
{
|
||||
id: 'createTimestamp',
|
||||
desc: true,
|
||||
},
|
||||
]);
|
||||
const [rowSelection, setRowSelection] = useState({});
|
||||
|
||||
const [{ pageIndex, pageSize }, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
});
|
||||
|
||||
const fetchDataOptions = {
|
||||
pageIndex,
|
||||
pageSize,
|
||||
};
|
||||
|
||||
const fetchData = (options: { pageIndex: number; pageSize: number }) => {
|
||||
return {
|
||||
rows: sessions.slice(
|
||||
options.pageIndex * options.pageSize,
|
||||
(options.pageIndex + 1) * options.pageSize,
|
||||
),
|
||||
pageCount: Math.ceil(sessions.length / options.pageSize),
|
||||
};
|
||||
};
|
||||
const pagination = useMemo(
|
||||
() => ({
|
||||
pageIndex,
|
||||
pageSize,
|
||||
}),
|
||||
[pageIndex, pageSize],
|
||||
);
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
columnHelper.display({
|
||||
id: 'select',
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
isChecked={table.getIsAllRowsSelected()}
|
||||
isIndeterminate={table.getIsSomeRowsSelected()}
|
||||
onChange={table.getToggleAllRowsSelectedHandler()}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
isChecked={row.getIsSelected()}
|
||||
isIndeterminate={row.getIsSomeSelected()}
|
||||
onChange={row.getToggleSelectedHandler()}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
columnHelper.accessor((row) => row.name, {
|
||||
cell: (info) => info.getValue(),
|
||||
header: 'Name',
|
||||
}),
|
||||
columnHelper.accessor((row) => row.createTimestamp, {
|
||||
id: 'createTimestamp',
|
||||
cell: (info) => new Date(info.getValue()).toLocaleString(),
|
||||
header: 'Created Time',
|
||||
sortDescFirst: true,
|
||||
}),
|
||||
columnHelper.accessor((row) => row.recorderVersion, {
|
||||
cell: (info) => info.getValue(),
|
||||
header: 'RRWEB Version',
|
||||
}),
|
||||
],
|
||||
[],
|
||||
);
|
||||
const table = useReactTable<Session>({
|
||||
columns,
|
||||
data: fetchData(fetchDataOptions).rows,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
onPaginationChange: setPagination,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onSortingChange: setSorting,
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
state: {
|
||||
pagination,
|
||||
sorting,
|
||||
rowSelection,
|
||||
},
|
||||
manualPagination: true,
|
||||
pageCount: fetchData(fetchDataOptions).pageCount,
|
||||
});
|
||||
|
||||
const updateSessions = async () => {
|
||||
const sessions = await getAllSessions();
|
||||
setSessions(sessions);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void updateSessions();
|
||||
channel.on(EventName.SessionUpdated, () => {
|
||||
void updateSessions();
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableContainer fontSize="md">
|
||||
<Table variant="simple">
|
||||
<Thead>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<Tr key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
const meta = header.column.columnDef.meta as
|
||||
| {
|
||||
isNumeric: boolean;
|
||||
}
|
||||
| undefined;
|
||||
return (
|
||||
<Th
|
||||
key={header.id}
|
||||
onClick={header.column.getToggleSortingHandler()}
|
||||
isNumeric={meta?.isNumeric}
|
||||
verticalAlign="center"
|
||||
userSelect="none"
|
||||
>
|
||||
<Flex align="center">
|
||||
{flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
<chakra.span pl={4}>
|
||||
{{
|
||||
asc: (
|
||||
<VscTriangleUp aria-label="sorted ascending" />
|
||||
),
|
||||
desc: (
|
||||
<VscTriangleDown aria-label="sorted descending" />
|
||||
),
|
||||
}[header.column.getIsSorted() as string] ?? null}
|
||||
</chakra.span>
|
||||
</Flex>
|
||||
</Th>
|
||||
);
|
||||
})}
|
||||
</Tr>
|
||||
))}
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<Tr key={row.id} _hover={{ cursor: 'pointer' }}>
|
||||
{row.getVisibleCells().map((cell, index) => {
|
||||
const meta = cell.column.columnDef.meta as
|
||||
| {
|
||||
isNumeric: boolean;
|
||||
}
|
||||
| undefined;
|
||||
return (
|
||||
<Td
|
||||
key={cell.id}
|
||||
isNumeric={meta?.isNumeric}
|
||||
onClick={() => {
|
||||
if (index !== 0)
|
||||
navigate(`/session/${row.original.id}`);
|
||||
}}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</Td>
|
||||
);
|
||||
})}
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<Flex mt={4}>
|
||||
<Flex gap={16} align="center" ml={4}>
|
||||
<Flex gap={1}>
|
||||
<IconButton
|
||||
aria-label={'Goto 1st Page'}
|
||||
onClick={() => table.setPageIndex(0)}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<FiChevronsLeft />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
aria-label={'Goto Previous Page'}
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<FiChevronLeft />
|
||||
</IconButton>
|
||||
</Flex>
|
||||
<Flex gap={1} fontSize="md">
|
||||
<Text>Page</Text>
|
||||
<Text as="b" w={12}>
|
||||
{`${
|
||||
table.getState().pagination.pageIndex + 1
|
||||
} of ${table.getPageCount()}`}
|
||||
</Text>
|
||||
</Flex>
|
||||
<Divider orientation="vertical" />
|
||||
<Flex gap={1} justify="center" align="center" fontSize="md">
|
||||
<Text w={28}>Go to page:</Text>
|
||||
<Input
|
||||
w={20}
|
||||
size="md"
|
||||
type="number"
|
||||
defaultValue={table.getState().pagination.pageIndex + 1}
|
||||
onChange={(e) => {
|
||||
const page = e.target.value ? Number(e.target.value) - 1 : 0;
|
||||
table.setPageIndex(page);
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
<Flex gap={1}>
|
||||
<IconButton
|
||||
aria-label={'Goto Next Page'}
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<FiChevronRight />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
aria-label={'Goto last Page'}
|
||||
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<FiChevronsRight />
|
||||
</IconButton>
|
||||
</Flex>
|
||||
</Flex>
|
||||
<Spacer />
|
||||
<Flex gap={8} align="center" mr={4}>
|
||||
<Select
|
||||
variant="outline"
|
||||
size="md"
|
||||
value={table.getState().pagination.pageSize}
|
||||
onChange={(e) => {
|
||||
table.setPageSize(Number(e.target.value));
|
||||
}}
|
||||
>
|
||||
{[10, 20, 30, 40, 50].map((pageSize) => (
|
||||
<option key={pageSize} value={pageSize}>
|
||||
Show {pageSize} items
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
{Object.keys(rowSelection).length > 0 && (
|
||||
<Button
|
||||
mr={4}
|
||||
size="md"
|
||||
colorScheme="red"
|
||||
onClick={() => {
|
||||
if (table.getSelectedRowModel().flatRows.length === 0) return;
|
||||
const ids = table
|
||||
.getSelectedRowModel()
|
||||
.flatRows.map((row) => row.original.id);
|
||||
void deleteSessions(ids).then(() => {
|
||||
setRowSelection({});
|
||||
void updateSessions();
|
||||
channel.emit(EventName.SessionUpdated, {});
|
||||
});
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</>
|
||||
);
|
||||
}
|
||||
9
packages/web-extension/src/pages/index.html
Normal file
9
packages/web-extension/src/pages/index.html
Normal file
@@ -0,0 +1,9 @@
|
||||
<!DOCTYPE html>
|
||||
<meta charset="utf-8" />
|
||||
<title>rrweb</title>
|
||||
<html>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
<script type="module" src="./index.tsx"></script>
|
||||
</html>
|
||||
22
packages/web-extension/src/pages/index.tsx
Normal file
22
packages/web-extension/src/pages/index.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
import { ChakraProvider } from '@chakra-ui/react';
|
||||
import * as ReactDOM from 'react-dom/client';
|
||||
import { createHashRouter, RouterProvider } from 'react-router-dom';
|
||||
import App from './App';
|
||||
|
||||
const rootElement = document.getElementById('root');
|
||||
const router = createHashRouter([
|
||||
{
|
||||
path: '/*',
|
||||
element: <App />,
|
||||
},
|
||||
]);
|
||||
|
||||
rootElement &&
|
||||
ReactDOM.createRoot(rootElement).render(
|
||||
<React.StrictMode>
|
||||
<ChakraProvider>
|
||||
<RouterProvider router={router} />
|
||||
</ChakraProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
Reference in New Issue
Block a user