Session timeline prerequisites (#58310)

* Add icon for show timeline button

* Add theme for the timeline

* Export the SVG to data URI function

* Add show timeline button to player, make it recalculate size on resize

* Update the player to broadcast time/allow external time control

* Update player wrapper to pass through ref/time update callback

* Stop xterm from stealing all the wheel events

* Stop xterm from stealing all the keyboard events

* Update ttyplayer to broadcast a smooth time update for the timeline

* Add local storage keys for different timeline settings

* Update types to match reality

* Theme fixes

* Add session timeline theme to bblp

* Add useUnmount, useDebounceCallback hooks

* Remove hooks as we're going to use usehooks-ts

* Add timeline canvas renderer abstract class to make other PRs easier

* Add comment about rewrite ws origin

* Change timeline button to toggle to match sidebar

* Add constants used in the timeline

* Move jest-canvas-mock to root package.json

* Patch jest-canvas-mock to have roundRect support

* Use ref prop instead of forwardRef

* Move to useResizeObserver

* Use regex for removing the semicolon from the font

* Update comment about xterm not blocking keys

* Add a comment about the useImperativeHandle usage

* Update types
This commit is contained in:
Ryan Clark
2025-08-27 15:50:39 +00:00
committed by GitHub
parent 5743c4bd83
commit 65fcf60603
26 changed files with 567 additions and 32 deletions
+1
View File
@@ -49,6 +49,7 @@
"@types/wicg-file-system-access": "^2023.10.6",
"cspell": "^9.2.0",
"jest": "^30.0.5",
"jest-canvas-mock": "^2.5.2",
"jsdom-testing-mocks": "^1.15.2",
"msw": "2.10.1",
"msw-storybook-addon": "^2.0.5",
+7 -7
View File
@@ -10,6 +10,9 @@ patchedDependencies:
'@types/[email protected]':
hash: d78cdc47c763f4a23344955ac2c09be11b33372d0f77eb4d6384f1286a75ece8
path: web/patches/@[email protected]
[email protected]:
hash: 8329a68f6b8f930f6204c405a071859893962d4a6641b6c2c2bb33122c0441b1
path: web/patches/[email protected]
importers:
@@ -172,6 +175,9 @@ importers:
jest:
specifier: ^30.0.5
version: 30.0.5(@types/[email protected])([email protected])([email protected]([email protected]))
jest-canvas-mock:
specifier: ^2.5.2
version: 2.5.2(patch_hash=8329a68f6b8f930f6204c405a071859893962d4a6641b6c2c2bb33122c0441b1)
jsdom-testing-mocks:
specifier: ^1.15.2
version: 1.15.2([email protected])
@@ -426,9 +432,6 @@ importers:
babel-plugin-transform-vite-meta-env:
specifier: ^1.0.3
version: 1.0.3
jest-canvas-mock:
specifier: ^2.5.2
version: 2.5.2
jest-websocket-mock:
specifier: ^2.5.0
version: 2.5.0
@@ -508,9 +511,6 @@ importers:
events:
specifier: 3.3.0
version: 3.3.0
jest-canvas-mock:
specifier: ^2.5.2
version: 2.5.2
react-dnd:
specifier: ^14.0.4
version: 14.0.5(@types/[email protected])(@types/[email protected])([email protected])
@@ -12127,7 +12127,7 @@ snapshots:
filelist: 1.0.4
minimatch: 3.1.2
[email protected]:
[email protected](patch_hash=8329a68f6b8f930f6204c405a071859893962d4a6641b6c2c2bb33122c0441b1):
dependencies:
cssfontparser: 1.2.1
moo-color: 1.0.2
+2
View File
@@ -39,3 +39,5 @@ packageImportMethod: clone-or-copy
patchedDependencies:
# Needed after the upgrade to TypeScript 5.9.2.
'@types/[email protected]': web/patches/@[email protected]
# Needed for roundRect support which was merged to jest-canvas-mock but not released.
[email protected]: web/patches/[email protected]
+1 -1
View File
@@ -147,7 +147,7 @@ export function createViteConfig(
changeOrigin: true,
secure: false,
ws: true,
rewriteWsOrigin: true,
rewriteWsOrigin: true, // rewrite the origin so Teleport doesn't reject the connection
},
'^\\/v[0-9]+\\/webapi\\/assistant\\/(.*?)': {
target: `https://${target}`,
@@ -122,6 +122,7 @@ export const Icons = () => (
<IconBox IconCmpt={Icon.EqualizersVertical} text="EqualizersVertical" />
<IconBox IconCmpt={Icon.Expand} text="Expand" />
<IconBox IconCmpt={Icon.Facebook} text="Facebook" />
<IconBox IconCmpt={Icon.FilmStrip} text="FilmStrip" />
<IconBox IconCmpt={Icon.FingerprintSimple} text="FingerprintSimple" />
<IconBox IconCmpt={Icon.Floppy} text="Floppy" />
<IconBox IconCmpt={Icon.FlowArrow} text="FlowArrow" />
@@ -0,0 +1,68 @@
/**
* Teleport
* Copyright (C) 2023 Gravitational, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
/* MIT License
Copyright (c) 2020 Phosphor Icons
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
import { forwardRef } from 'react';
import { Icon, IconProps } from '../Icon';
/*
THIS FILE IS GENERATED. DO NOT EDIT.
*/
export const FilmStrip = forwardRef<HTMLSpanElement, IconProps>(
({ size = 24, color, ...otherProps }, ref) => (
<Icon
size={size}
color={color}
className="icon icon-filmstrip"
{...otherProps}
ref={ref}
>
<path
fillRule="evenodd"
d="M3.34 3.814a1.46 1.46 0 0 0-.666.413c-.226.235-.333.44-.39.752-.063.338-.062 13.774.001 14.069.113.535.516.979 1.029 1.135.279.084 17.093.084 17.372 0 .525-.16.929-.615 1.03-1.162.062-.331.062-13.711 0-14.042a1.37 1.37 0 0 0-.423-.784 1.5 1.5 0 0 0-.673-.393c-.239-.062-17.065-.05-17.28.012M6.76 6v.76h-3V5.24h3zm4.48 0v.76h-3V5.24h3zm4.52 0v.76h-3V5.24h3zm4.48 0v.76h-3V5.24h3zm-9 6v3.76H3.76V8.24h7.48zm9 0v3.76h-7.48V8.24h7.48zM6.76 18v.76h-3v-1.52h3zm4.48 0v.76h-3v-1.52h3zm4.52 0v.76h-3v-1.52h3zm4.48 0v.76h-3v-1.52h3z"
/>
</Icon>
)
);
@@ -0,0 +1 @@
<svg width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M3.34 3.814a1.459 1.459 0 0 0-.666.413c-.226.235-.333.44-.39.752-.063.338-.062 13.774.001 14.069.113.535.516.979 1.029 1.135.279.084 17.093.084 17.372 0 .525-.16.929-.615 1.03-1.162.062-.331.062-13.711 0-14.042a1.366 1.366 0 0 0-.423-.784 1.511 1.511 0 0 0-.673-.393c-.239-.062-17.065-.05-17.28.012M6.76 6v.76h-3V5.24h3V6m4.48 0v.76h-3V5.24h3V6m4.52 0v.76h-3V5.24h3V6m4.48 0v.76h-3V5.24h3V6m-9 6v3.76H3.76V8.24h7.48V12m9 0v3.76h-7.48V8.24h7.48V12M6.76 18v.76h-3v-1.52h3V18m4.48 0v.76h-3v-1.52h3V18m4.52 0v.76h-3v-1.52h3V18m4.48 0v.76h-3v-1.52h3V18" fill-rule="evenodd" fill="#000"/></svg>

After

Width:  |  Height:  |  Size: 672 B

+1
View File
@@ -111,6 +111,7 @@ export { EnvelopeOpen } from './Icons/EnvelopeOpen';
export { EqualizersVertical } from './Icons/EqualizersVertical';
export { Expand } from './Icons/Expand';
export { Facebook } from './Icons/Facebook';
export { FilmStrip } from './Icons/FilmStrip';
export { FingerprintSimple } from './Icons/FingerprintSimple';
export { Floppy } from './Icons/Floppy';
export { FlowArrow } from './Icons/FlowArrow';
@@ -496,6 +496,43 @@ const colors: ThemeColors = {
user: '#C5B6FF',
},
sessionRecordingTimeline: {
background: '#1f2549',
headerBackground: 'rgba(0, 0, 0, 0.13)',
frameBorder: 'rgba(255, 255, 255, 0.2)',
progressLine: '#E53E3E',
border: {
default: '#4c516e',
hover: '#5f659e',
},
cursor: 'rgba(255, 255, 255, 0.4)',
events: {
inactivity: {
background: 'rgba(159,133,255, 0.25)',
text: 'rgba(255, 255, 255, 0.6)',
},
resize: {
background: '#26323c',
border: '#86c4ed',
text: '#86c4ed',
},
join: {
background: '#009EFF',
text: 'rgba(0, 0, 0, 0.87)',
},
default: {
background: 'rgba(255, 255, 255, 0.54)',
text: '',
},
},
timeMarks: {
primary: '#718096',
secondary: '#4A5568',
absolute: '#E2E8F0',
text: '#A0AEC0',
},
},
link: '#66ABFF',
highlightedNavigationItem: 'rgba(255, 255, 255, 0.3)',
@@ -504,6 +504,43 @@ const colors: ThemeColors = {
user: '#C5B6FF',
},
sessionRecordingTimeline: {
background: '#1f2549',
headerBackground: 'rgba(0, 0, 0, 0.13)',
frameBorder: 'rgba(255, 255, 255, 0.2)',
progressLine: '#E53E3E',
border: {
default: '#4c516e',
hover: '#5f659e',
},
cursor: 'rgba(255, 255, 255, 0.4)',
events: {
inactivity: {
background: 'rgba(159,133,255, 0.25)',
text: 'rgba(255, 255, 255, 0.6)',
},
resize: {
background: '#26323c',
border: '#86c4ed',
text: '#86c4ed',
},
join: {
background: '#009EFF',
text: 'rgba(0, 0, 0, 0.87)',
},
default: {
background: 'rgba(255, 255, 255, 0.54)',
text: '',
},
},
timeMarks: {
primary: '#718096',
secondary: '#4A5568',
absolute: '#E2E8F0',
text: '#A0AEC0',
},
},
link: '#009EFF',
highlightedNavigationItem: 'rgba(255, 255, 255, 0.3)',
@@ -499,6 +499,43 @@ const colors: ThemeColors = {
user: '#311C79',
},
sessionRecordingTimeline: {
background: '#FBFBFC',
headerBackground: 'rgba(0, 0, 0, 0.05)',
frameBorder: 'rgba(0, 0, 0, 0.2)',
progressLine: '#E53E3E',
border: {
default: '#4c516e',
hover: '#5f659e',
},
cursor: 'rgba(0, 0, 0, 0.4)',
events: {
inactivity: {
background: 'rgba(81,47,201, 0.25)',
text: 'rgba(0, 0, 0, 0.6)',
},
resize: {
border: '#26323c',
background: '#86c4ed',
text: '#26323c',
},
join: {
background: '#0073BA',
text: 'rgba(255, 255, 255, 0.8)',
},
default: {
background: 'rgba(0, 0, 0, 0.54)',
text: '#000',
},
},
timeMarks: {
primary: 'rgba(0,0,0,0.54)',
secondary: 'rgba(0,0,0,0.36)',
absolute: 'rgba(0,0,0,0.87)',
text: 'rgba(0,0,0,0.87)',
},
},
link: '#0073BA',
highlightedNavigationItem: blue[200],
@@ -227,6 +227,43 @@ export type ThemeColors = {
user: string;
};
sessionRecordingTimeline: {
background: string;
headerBackground: string;
frameBorder: string;
progressLine: string;
cursor: string;
border: {
default: string;
hover: string;
};
events: {
inactivity: {
background: string;
text: string;
};
resize: {
background: string;
border: string;
text: string;
};
join: {
background: string;
text: string;
};
default: {
background: string;
text: string;
};
};
timeMarks: {
primary: string;
secondary: string;
absolute: string;
text: string;
};
};
link: string;
highlightedNavigationItem: string;
-1
View File
@@ -46,7 +46,6 @@
"@gravitational/teleport": "workspace:*",
"babel-plugin-transform-import-meta": "2.3.3",
"babel-plugin-transform-vite-meta-env": "^1.0.3",
"jest-canvas-mock": "^2.5.2",
"jest-websocket-mock": "^2.5.0"
}
}
@@ -100,8 +100,8 @@ export const MOCK_THUMBNAIL: SessionRecordingThumbnail = {
cursorVisible: true,
cols: 100,
rows: 100,
startTime: 0,
endTime: 0,
startOffset: 0,
endOffset: 0,
};
export function getThumbnail(thumbnail: SessionRecordingThumbnail) {
@@ -75,7 +75,7 @@ export function generateTerminalSVGStyleTag(theme: DefaultTheme): string {
// svgToDataURIBase64 converts an SVG string to a Base64-encoded data URI, providing
// a way to load SVG images in the code.
function svgToDataURIBase64(svg: string) {
export function svgToDataURIBase64(svg: string) {
const encoded = encodeURIComponent(svg.replace('<?xml version="1.0"?>', ''));
return `data:image/svg+xml;charset=utf-8,${encoded}`;
@@ -16,23 +16,51 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React, { memo } from 'react';
import React, { memo, useCallback, useEffect, useRef } from 'react';
import styled from 'styled-components';
import Flex from 'design/Flex';
import * as Icons from 'design/Icon';
import { Layout } from 'design/Icon';
import { HoverTooltip } from 'design/Tooltip';
import { useResizeObserver } from 'design/utils/useResizeObserver';
import Slider from './Slider';
interface SliderHandle {
_handleResize: () => void;
}
export default function ProgressBar(props: ProgressBarProps) {
const Icon = props.isPlaying ? Icons.CirclePause : Icons.CirclePlay;
const sliderRef = useRef<SliderHandle>(null);
const containerRef = useResizeObserver(
useCallback(() => {
if (!sliderRef.current) {
return;
}
sliderRef.current._handleResize();
}, [])
);
const { onToggleTimeline } = props;
useEffect(() => {
if (!sliderRef.current) {
return;
}
sliderRef.current._handleResize();
}, [onToggleTimeline]);
return (
<StyledProgessBar
style={props.style}
id={props.id}
disabled={props.disabled}
ref={containerRef}
>
<Flex gap={3}>
{props.onToggleSidebar && (
@@ -53,6 +81,7 @@ export default function ProgressBar(props: ProgressBarProps) {
<TimeText>{props.time}</TimeText>
<SliderContainer>
<Slider
ref={sliderRef}
min={props.min}
max={props.max}
value={props.current}
@@ -65,6 +94,18 @@ export default function ProgressBar(props: ProgressBarProps) {
/>
</SliderContainer>
<Restart onRestart={props.onRestart} />
{onToggleTimeline && (
<HoverTooltip tipContent="Toggle Timeline">
<ActionButton
style={{
marginLeft: '16px',
}}
onClick={onToggleTimeline}
>
<Icons.FilmStrip />
</ActionButton>
</HoverTooltip>
)}
</StyledProgessBar>
);
}
@@ -101,6 +142,7 @@ export type ProgressBarProps = {
onPlaySpeedChange?: (newSpeed: number) => void;
onRestart?: () => void;
onToggleSidebar?: () => void;
onToggleTimeline?: () => void;
};
const PlaySpeedSelector = memo(
@@ -16,18 +16,24 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { type RefObject } from 'react';
import styled from 'styled-components';
import type { RecordingType } from 'teleport/services/recordings';
import { DesktopPlayer } from 'teleport/SessionRecordings/view/DesktopPlayer';
import SshPlayer from 'teleport/SessionRecordings/view/SshPlayer';
import SshPlayer, {
type PlayerHandle,
} from 'teleport/SessionRecordings/view/SshPlayer';
interface RecordingPlayerProps {
clusterId: string;
durationMs: number;
onTimeChange?: (time: number) => void;
recordingType: RecordingType;
sessionId: string;
onToggleSidebar?: () => void;
onToggleTimeline?: () => void;
ref?: RefObject<PlayerHandle>;
}
const Container = styled.div`
@@ -41,9 +47,12 @@ const Container = styled.div`
export function RecordingPlayer({
clusterId,
durationMs,
onTimeChange,
onToggleSidebar,
recordingType,
sessionId,
onToggleSidebar,
onToggleTimeline,
ref,
}: RecordingPlayerProps) {
if (recordingType === 'desktop') {
return (
@@ -60,10 +69,13 @@ export function RecordingPlayer({
return (
<Container>
<SshPlayer
ref={ref}
onTimeChange={onTimeChange}
onToggleSidebar={onToggleSidebar}
sid={sessionId}
clusterId={clusterId}
durationMs={durationMs}
onToggleSidebar={onToggleSidebar}
onToggleTimeline={onToggleTimeline}
/>
</Container>
);
@@ -16,7 +16,14 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { useEffect, useMemo, useState } from 'react';
import {
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useState,
type RefObject,
} from 'react';
import styled from 'styled-components';
import { Box, Flex, Indicator } from 'design';
@@ -34,18 +41,29 @@ interface PlayerProps {
sid: string;
clusterId: string;
durationMs: number;
onTimeChange?: (time: number) => void;
onToggleTimeline?: () => void;
onToggleSidebar?: () => void;
ref?: RefObject<PlayerHandle>;
}
export default function Player({
export interface PlayerHandle {
moveToTime: (time: number) => void;
}
function Player({
sid,
clusterId,
durationMs,
onTimeChange,
onToggleSidebar,
onToggleTimeline,
ref,
}: PlayerProps) {
const { tty, playerStatus, statusText, time } = useStreamingSshPlayer(
clusterId,
sid
sid,
onTimeChange
);
// statusText is currently only set when an error happens, so for now we can assume
@@ -56,6 +74,19 @@ export default function Player({
const isPlaying = playerStatus === StatusEnum.PLAYING;
const isComplete = isError || playerStatus === StatusEnum.COMPLETE;
const moveToTime = useCallback(
(newTime: number) => {
tty.move(newTime);
},
[tty]
);
// expose a moveToTime method through the ref so we can update the time from the outside
// without having to pass it in as a prop and cause a re-render.
useImperativeHandle(ref, () => ({
moveToTime,
}));
if (isError) {
return (
<StatusBox>
@@ -94,11 +125,14 @@ export default function Player({
isPlaying ? tty.stop() : tty.play();
}}
onToggleSidebar={onToggleSidebar}
onToggleTimeline={onToggleTimeline}
/>
</StyledPlayer>
);
}
export { Player as default };
const StatusBox = props => (
<Box width="100%" textAlign="center" p={3} {...props} />
);
@@ -111,7 +145,11 @@ const StyledPlayer = styled.div`
justify-content: space-between;
`;
function useStreamingSshPlayer(clusterId: string, sid: string) {
function useStreamingSshPlayer(
clusterId: string,
sid: string,
onTimeChange?: (time: number) => void
) {
const [playerStatus, setPlayerStatus] = useState(StatusEnum.LOADING);
const [statusText, setStatusText] = useState('');
const [time, setTime] = useState(0);
@@ -122,8 +160,14 @@ function useStreamingSshPlayer(clusterId: string, sid: string) {
.replace(':clusterId', clusterId)
.replace(':sid', sid)
.replace(':token', getAccessToken());
return new TtyPlayer({ url, setPlayerStatus, setStatusText, setTime });
}, [clusterId, sid, setPlayerStatus, setStatusText, setTime]);
return new TtyPlayer({
url,
onTimeChange,
setPlayerStatus,
setStatusText,
setTime,
});
}, [clusterId, sid, setPlayerStatus, setStatusText, onTimeChange]);
useEffect(() => {
tty.connect();
@@ -0,0 +1,31 @@
/**
* Teleport
* Copyright (C) 2025 Gravitational, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { font } from 'design/theme/fonts';
export const LEFT_PADDING = 24; // the left padding of the timeline from the edge of the screen
export const EVENT_ROW_HEIGHT = 28; // height of each row of events
export const EVENT_SECTION_PADDING = 8; // padding between the ruler, events section and the frames section
export const RULER_HEIGHT = 35; // height of the time ruler at the top of the timeline
export const BASE_FRAME_WIDTH = 240; // base width of the frame, used to calculate the scale ratio of the frame
export const DEFAULT_FRAME_HEIGHT = 250; // default height of a frame
export const DEFAULT_MAX_FRAME_WIDTH = 250; // the maximum width a frame can be
export const CANVAS_FONT = font.replace(/;$/, ''); // remove the semicolon at the end
@@ -0,0 +1,54 @@
/**
* Teleport
* Copyright (C) 2025 Gravitational, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import type { DefaultTheme } from 'styled-components';
export interface TimelineRenderContext {
containerHeight: number;
containerWidth: number;
eventsHeight: number;
offset: number;
}
export abstract class TimelineCanvasRenderer {
protected timelineWidth = 0;
constructor(
protected ctx: CanvasRenderingContext2D,
protected theme: DefaultTheme,
protected duration: number
) {}
abstract _render(context: TimelineRenderContext): void;
abstract calculate(): void;
render(context: TimelineRenderContext) {
this.ctx.save();
this._render(context);
this.ctx.restore();
}
setTimelineWidth(timelineWidth: number) {
this.timelineWidth = timelineWidth;
this.calculate();
}
}
@@ -27,6 +27,10 @@ import { TermEvent } from 'teleport/lib/term/enums';
import Terminal from 'teleport/lib/term/terminal';
import Tty from 'teleport/lib/term/tty';
// These keys are used for keyboard shortcuts in the session timeline,
// so we need to make sure that xterm.js doesn't trap the keyboard events.
const doNotBlockKeys = ['?', 't', 'h', 's', 'Escape'];
export default function Xterm({ tty }: { tty: Tty }) {
const refContainer = useRef<HTMLDivElement>(null);
const theme = useTheme();
@@ -46,6 +50,10 @@ export default function Xterm({ tty }: { tty: Tty }) {
}, []);
useEffect(() => {
if (!refContainer.current) {
return;
}
const term = new TerminalPlayer(tty, {
el: refContainer.current,
fontFamily: theme.fonts.mono,
@@ -57,6 +65,10 @@ export default function Xterm({ tty }: { tty: Tty }) {
term.open();
term.term.focus();
term.registerCustomKeyEventHandler(
(e: KeyboardEvent) => !doNotBlockKeys.includes(e.key)
);
term.tty.on(TermEvent.DATA, () => {
// Keeps the cursor in view.
term.term.textarea.scrollIntoView(false);
@@ -66,13 +78,15 @@ export default function Xterm({ tty }: { tty: Tty }) {
e.stopPropagation();
}
const container = refContainer.current;
// Stop wheel event from reaching the terminal
// to allow parent container of xterm to scroll instead.
window.addEventListener('wheel', stopPropagating, true);
container.addEventListener('wheel', stopPropagating, { passive: true });
function cleanup() {
term.destroy();
window.removeEventListener('wheel', stopPropagating, true);
container.removeEventListener('wheel', stopPropagating);
}
return cleanup;
@@ -41,13 +41,15 @@ const actionPause = 1;
const PROGRESS_UPDATE_INTERVAL_MS = 50;
export default class TtyPlayer extends Tty {
constructor({ url, setPlayerStatus, setStatusText, setTime }) {
constructor({ url, onTimeChange, setPlayerStatus, setStatusText, setTime }) {
super({});
this._url = url;
this._setPlayerStatus = setPlayerStatus;
this._setStatusText = setStatusText;
this._onTimeChange = onTimeChange;
this._paused = false;
this._lastPlayedTimestamp = 0;
this._skipTimeUpdatesUntil = null;
@@ -57,6 +59,10 @@ export default class TtyPlayer extends Tty {
this._lastUpdateTime = 0;
this._lastTimestamp = 0;
this._timeout = null;
// Add animation frame tracking
this._animationFrameId = null;
this._isAnimating = false;
}
// Override the base class connection, which uses the envelope-based
@@ -106,6 +112,7 @@ export default class TtyPlayer extends Tty {
disconnect(closeCode = WebsocketCloseCode.NORMAL) {
this.cancelTimeUpdate();
this.stopAnimationLoop();
if (this.webSocket !== null) {
this.webSocket.close(closeCode);
}
@@ -128,6 +135,7 @@ export default class TtyPlayer extends Tty {
clearTimeout(this._timeout);
this._timeout = null;
}
this.stopAnimationLoop();
}
onMessage(m) {
@@ -171,6 +179,7 @@ export default class TtyPlayer extends Tty {
// as quickly as possible
if (!this._paused && !this.isSeekingForward()) {
this.scheduleNextUpdate(delay);
this.startAnimationLoop();
}
break;
@@ -207,6 +216,8 @@ export default class TtyPlayer extends Tty {
move(newPos) {
this.cancelTimeUpdate();
this.stopAnimationLoop();
try {
const buffer = new ArrayBuffer(11);
const dv = new DataView(buffer);
@@ -219,6 +230,9 @@ export default class TtyPlayer extends Tty {
}
this._setTime(newPos);
if (this._onTimeChange) {
this._onTimeChange(newPos);
}
this._lastUpdateTime = Date.now();
this._skipTimeUpdatesUntil = newPos;
@@ -227,6 +241,7 @@ export default class TtyPlayer extends Tty {
} else {
if (!this._paused) {
this.scheduleNextUpdate(newPos);
this.startAnimationLoop();
}
}
}
@@ -241,6 +256,7 @@ export default class TtyPlayer extends Tty {
stop() {
this._paused = true;
this.cancelTimeUpdate();
this.stopAnimationLoop();
this._setPlayerStatus(StatusEnum.PAUSED);
if (this.webSocket.readyState !== WebSocket.OPEN) {
@@ -268,6 +284,8 @@ export default class TtyPlayer extends Tty {
this.scheduleNextUpdate(this._lastTimestamp);
}
this.startAnimationLoop();
// the very first play call happens before we've even
// connected - we only need to send the websocket message
// for subsequent calls
@@ -282,4 +300,42 @@ export default class TtyPlayer extends Tty {
dv.setUint8(3, actionPlay);
this.webSocket.send(dv);
}
startAnimationLoop() {
if (this._isAnimating) {
return;
}
this._isAnimating = true;
const animate = () => {
if (!this._isAnimating) {
return;
}
// Calculate current time based on elapsed time since last update
if (!this._paused) {
const delta = Date.now() - this._lastUpdateTime;
const currentTime = this._lastTimestamp + delta;
// Call onTimeChange smoothly every frame
if (this._onTimeChange && this._sendTimeUpdates) {
this._onTimeChange(currentTime);
}
}
this._animationFrameId = requestAnimationFrame(animate);
};
this._animationFrameId = requestAnimationFrame(animate);
}
stopAnimationLoop() {
this._isAnimating = false;
if (this._animationFrameId) {
cancelAnimationFrame(this._animationFrameId);
this._animationFrameId = null;
}
}
}
@@ -73,8 +73,8 @@ export interface SessionRecordingThumbnail {
cursorX: number;
cursorY: number;
cursorVisible: boolean;
startTime: number;
endTime: number;
startOffset: number;
endOffset: number;
}
export interface SessionRecordingMetadata {
@@ -112,8 +112,8 @@ export type SessionRecordingMessage =
| WrappedMessage<SessionRecordingMessageType.Error, SessionRecordingError>;
interface BaseSessionRecordingEvent {
startOffsetMs: number;
endOffsetMs: number;
startTime: number;
endTime: number;
}
interface SessionRecordingInactivityEvent extends BaseSessionRecordingEvent {
@@ -125,13 +125,13 @@ interface SessionRecordingJoinEvent extends BaseSessionRecordingEvent {
user: string;
}
interface SessionRecordingResizeEvent extends BaseSessionRecordingEvent {
export interface SessionRecordingResizeEvent extends BaseSessionRecordingEvent {
type: SessionRecordingEventType.Resize;
cols: number;
rows: number;
}
type SessionRecordingEvent =
export type SessionRecordingEvent =
| SessionRecordingJoinEvent
| SessionRecordingResizeEvent
| SessionRecordingInactivityEvent;
@@ -49,6 +49,14 @@ export const KeysEnum = {
'grv_teleport_session_recordings_dismissed_cta',
SESSION_RECORDINGS_DISMISSED_SETUP:
'grv_teleport_session_recordings_dismissed_setup',
SESSION_RECORDING_SIDEBAR_HIDDEN:
'grv_teleport_session_recording_sidebar_hidden',
SESSION_RECORDING_TIMELINE_HEIGHT:
'grv_teleport_session_recording_timeline_height',
SESSION_RECORDING_TIMELINE_HIDDEN:
'grv_teleport_session_recording_timeline_hidden',
SESSION_RECORDING_TIMELINE_SHOW_ABSOLUTE_TIME:
'grv_teleport_session_recording_timeline_show_absolute_time',
// TODO(bl-nero): Remove once the new role editor is in acceptable state.
USE_NEW_ROLE_EDITOR: 'grv_teleport_use_new_role_editor',
-1
View File
@@ -50,7 +50,6 @@
"electron-updater": "^6.6.7",
"electron-vite": "^4.0.0",
"events": "3.3.0",
"jest-canvas-mock": "^2.5.2",
"react-dnd": "^14.0.4",
"react-dnd-html5-backend": "^14.0.2",
"whatwg-url": "^14.2.0",
+54
View File
@@ -0,0 +1,54 @@
diff --git a/lib/classes/CanvasRenderingContext2D.js b/lib/classes/CanvasRenderingContext2D.js
index e1e4b8fe31826baadbdce3f48ee2a68455de8910..46dad56798ddc7fa094f911a7a7e0c97fb2a3770 100644
--- a/lib/classes/CanvasRenderingContext2D.js
+++ b/lib/classes/CanvasRenderingContext2D.js
@@ -15,7 +15,7 @@ function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { de
function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); }
-const testFuncs = ['setLineDash', 'getLineDash', 'setTransform', 'getTransform', 'getImageData', 'save', 'restore', 'createPattern', 'createRadialGradient', 'addHitRegion', 'arc', 'arcTo', 'beginPath', 'clip', 'closePath', 'scale', 'stroke', 'clearHitRegions', 'clearRect', 'fillRect', 'strokeRect', 'rect', 'resetTransform', 'translate', 'moveTo', 'lineTo', 'bezierCurveTo', 'createLinearGradient', 'ellipse', 'measureText', 'rotate', 'drawImage', 'drawFocusIfNeeded', 'isPointInPath', 'isPointInStroke', 'putImageData', 'strokeText', 'fillText', 'quadraticCurveTo', 'removeHitRegion', 'fill', 'transform', 'scrollPathIntoView', 'createImageData'];
+const testFuncs = ['setLineDash', 'getLineDash', 'setTransform', 'getTransform', 'getImageData', 'save', 'restore', 'createPattern', 'createRadialGradient', 'addHitRegion', 'arc', 'arcTo', 'beginPath', 'clip', 'closePath', 'scale', 'stroke', 'clearHitRegions', 'clearRect', 'fillRect', 'strokeRect', 'rect', 'roundRect', 'resetTransform', 'translate', 'moveTo', 'lineTo', 'bezierCurveTo', 'createLinearGradient', 'ellipse', 'measureText', 'rotate', 'drawImage', 'drawFocusIfNeeded', 'isPointInPath', 'isPointInStroke', 'putImageData', 'strokeText', 'fillText', 'quadraticCurveTo', 'removeHitRegion', 'fill', 'transform', 'scrollPathIntoView', 'createImageData'];
const compositeOperations = ['source-over', 'source-in', 'source-out', 'source-atop', 'destination-over', 'destination-in', 'destination-out', 'destination-atop', 'lighter', 'copy', 'xor', 'multiply', 'screen', 'overlay', 'darken', 'lighten', 'color-dodge', 'color-burn', 'hard-light', 'soft-light', 'difference', 'exclusion', 'hue', 'saturation', 'color', 'luminosity'];
function getTransformSlice(ctx) {
return ctx._transformStack[ctx._stackIndex].slice();
@@ -909,6 +909,40 @@ class CanvasRenderingContext2D {
const event = (0, _createCanvasEvent.default)('restore', getTransformSlice(this), {});
this._events.push(event);
}
+ roundRect(x, y, width, height, radii) {
+ if (arguments.length < 4)
+ throw new TypeError(
+ "Failed to execute 'roundRect' on '" +
+ this.constructor.name +
+ "': 4 arguments required, but only " +
+ arguments.length +
+ ' present.'
+ );
+ if (radii.constructor === Array && (radii.length === 0 || radii.length > 4))
+ throw new TypeError(
+ "Failed to execute 'roundRect' on '" +
+ this.constructor.name +
+ "': " +
+ radii.length +
+ ' radii provided. Between one and four radii are necessary.'
+ );
+ if (!Number.isFinite(x + y + width + height)) return;
+
+ const xResult = Number(x);
+ const yResult = Number(y);
+ const widthResult = Number(width);
+ const heightResult = Number(height);
+ const event = (0, _createCanvasEvent.default)('roundRect', getTransformSlice(this), {
+ x: xResult,
+ y: yResult,
+ width: widthResult,
+ height: heightResult,
+ radii: radii,
+ });
+
+ this._events.push(event);
+ this._path.push(event);
+ }
rotate(angle) {
if (arguments.length < 1) throw new TypeError("Failed to execute 'rotate' on '" + this.constructor.name + "': 1 argument required, but only 0 present.");
angle = Number(angle);