Files
react/packages/react-devtools-timeline/src/CanvasPageContextMenu.js
Ruslan Lesiutin 221f3002ca chore[DevTools]: make clipboardWrite optional for chromium (#32262)
Addresses https://github.com/facebook/react/issues/32244.

### Chromium
We will use
[chrome.permissions](https://developer.chrome.com/docs/extensions/reference/api/permissions)
for checking / requesting `clipboardWrite` permission before copying
something to the clipboard.

### Firefox
We will keep `clipboardWrite` as a required permission, because there is
no reliable and working API for requesting optional permissions for
extensions that are extending browser DevTools:
- `chrome.permissions` is unavailable for devtools pages -
https://bugzilla.mozilla.org/show_bug.cgi?id=1796933
- You can't call `chrome.permissions.request` from background, because
this instruction has to be executed inside user-event callback,
basically only initiated by user.

I don't really want to come up with solutions like opening a new tab
with a button that user has to click.
2025-01-30 20:08:17 +00:00

183 lines
4.5 KiB
JavaScript

/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
import * as React from 'react';
import {useMemo} from 'react';
import {copy} from 'clipboard-js';
import prettyMilliseconds from 'pretty-ms';
import ContextMenuContainer from 'react-devtools-shared/src/devtools/ContextMenu/ContextMenuContainer';
import {withPermissionsCheck} from 'react-devtools-shared/src/frontend/utils/withPermissionsCheck';
import {getBatchRange} from './utils/getBatchRange';
import {moveStateToRange} from './view-base/utils/scrollState';
import {MAX_ZOOM_LEVEL, MIN_ZOOM_LEVEL} from './view-base/constants';
import type {
ContextMenuItem,
ContextMenuRef,
} from 'react-devtools-shared/src/devtools/ContextMenu/types';
import type {
ReactEventInfo,
ReactMeasure,
TimelineData,
ViewState,
} from './types';
function zoomToBatch(
data: TimelineData,
measure: ReactMeasure,
viewState: ViewState,
width: number,
) {
const {batchUID} = measure;
const [rangeStart, rangeEnd] = getBatchRange(batchUID, data);
// Convert from time range to ScrollState
const scrollState = moveStateToRange({
state: viewState.horizontalScrollState,
rangeStart,
rangeEnd,
contentLength: data.duration,
minContentLength: data.duration * MIN_ZOOM_LEVEL,
maxContentLength: data.duration * MAX_ZOOM_LEVEL,
containerLength: width,
});
viewState.updateHorizontalScrollState(scrollState);
}
function copySummary(data: TimelineData, measure: ReactMeasure) {
const {batchUID, duration, timestamp, type} = measure;
const [startTime, stopTime] = getBatchRange(batchUID, data);
copy(
JSON.stringify({
type,
timestamp: prettyMilliseconds(timestamp),
duration: prettyMilliseconds(duration),
batchDuration: prettyMilliseconds(stopTime - startTime),
}),
);
}
type Props = {
canvasRef: {current: HTMLCanvasElement | null},
hoveredEvent: ReactEventInfo | null,
timelineData: TimelineData,
viewState: ViewState,
canvasWidth: number,
closedMenuStub: React.Node,
ref: ContextMenuRef,
};
export default function CanvasPageContextMenu({
canvasRef,
timelineData,
hoveredEvent,
viewState,
canvasWidth,
closedMenuStub,
ref,
}: Props): React.Node {
const menuItems = useMemo<ContextMenuItem[]>(() => {
if (hoveredEvent == null) {
return [];
}
const {
componentMeasure,
flamechartStackFrame,
measure,
networkMeasure,
schedulingEvent,
suspenseEvent,
} = hoveredEvent;
const items: ContextMenuItem[] = [];
if (componentMeasure != null) {
items.push({
onClick: () => copy(componentMeasure.componentName),
content: 'Copy component name',
});
}
if (networkMeasure != null) {
items.push({
onClick: () => copy(networkMeasure.url),
content: 'Copy URL',
});
}
if (schedulingEvent != null) {
items.push({
onClick: () => copy(schedulingEvent.componentName),
content: 'Copy component name',
});
}
if (suspenseEvent != null) {
items.push({
onClick: () => copy(suspenseEvent.componentName),
content: 'Copy component name',
});
}
if (measure != null) {
items.push(
{
onClick: () =>
zoomToBatch(timelineData, measure, viewState, canvasWidth),
content: 'Zoom to batch',
},
{
onClick: withPermissionsCheck({permissions: ['clipboardWrite']}, () =>
copySummary(timelineData, measure),
),
content: 'Copy summary',
},
);
}
if (flamechartStackFrame != null) {
items.push(
{
onClick: withPermissionsCheck({permissions: ['clipboardWrite']}, () =>
copy(flamechartStackFrame.scriptUrl),
),
content: 'Copy file path',
},
{
onClick: withPermissionsCheck({permissions: ['clipboardWrite']}, () =>
copy(
`line ${flamechartStackFrame.locationLine ?? ''}, column ${
flamechartStackFrame.locationColumn ?? ''
}`,
),
),
content: 'Copy location',
},
);
}
return items;
}, [hoveredEvent, viewState, canvasWidth]);
return (
<ContextMenuContainer
anchorElementRef={canvasRef}
items={menuItems}
closedMenuStub={closedMenuStub}
ref={ref}
/>
);
}