mirror of
https://github.com/gravitational/teleport.git
synced 2026-10-12 02:15:01 +00:00
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:
@@ -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",
|
||||
|
||||
Generated
+7
-7
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 |
@@ -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;
|
||||
|
||||
@@ -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
|
||||
+54
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user