fix: fix logviewer selection behavior

Signed-off-by: Katharina Sick <katharina.sick@dynatrace.com>
This commit is contained in:
Katharina Sick
2025-11-10 14:49:39 +01:00
parent 836b0c7552
commit 9a942a417d
5 changed files with 613 additions and 168 deletions
+12
View File
@@ -0,0 +1,12 @@
---
'@backstage/core-components': patch
---
Fixed bug in the `LogViewer` component where shift + click always opened a new window instead of just changing the selection.
In addition, improved the `LogViewer` component by a few usability enhancements:
- Added support for multiple selections using cmd/ctrl + click
- Improved the generated hash that is added to the URL to also support ranges & multiple selections
- Added an hover effect & info tooltip to the "Copy to clipboard" button to indicate its functionality
- Added some color and a separator to the line numbers to improve readability
@@ -21,7 +21,7 @@ import classnames from 'classnames';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useLocation } from 'react-router-dom';
import AutoSizer from 'react-virtualized-auto-sizer';
import { VariableSizeList, FixedSizeList } from 'react-window';
import { FixedSizeList, VariableSizeList } from 'react-window';
import { AnsiLine, AnsiProcessor } from './AnsiProcessor';
import { LogLine } from './LogLine';
@@ -29,6 +29,7 @@ import { LogViewerControls } from './LogViewerControls';
import { HEADER_SIZE, useStyles } from './styles';
import { useLogViewerSearch } from './useLogViewerSearch';
import { useLogViewerSelection } from './useLogViewerSelection';
import Snackbar from '@material-ui/core/Snackbar';
export interface RealLogViewerProps {
text: string;
@@ -47,6 +48,7 @@ export function RealLogViewer(props: RealLogViewerProps) {
// The processor keeps state that optimizes appending to the text
const processor = useMemo(() => new AnsiProcessor(), []);
const lines = processor.process(props.text);
const [showCopyInfo, setShowCopyInfo] = useState(false);
const search = useLogViewerSearch(lines);
const selection = useLogViewerSelection(lines);
@@ -69,19 +71,39 @@ export function RealLogViewer(props: RealLogViewerProps) {
}
}, [listInstance, search.resultLine, lines]);
useEffect(() => {
const hash = selection.getHash();
if (hash.length > 0) {
history.replaceState(null, '', hash);
}
}, [selection]);
useEffect(() => {
if (location.hash) {
// #line-6 -> 6
const line = parseInt(location.hash.replace(/\D/g, ''), 10);
selection.setSelection(line, false);
selection.selectAll(location.hash);
}
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const handleSelectLine = (
line: number,
event: { shiftKey: boolean; preventDefault: () => void },
event: {
shiftKey: boolean;
metaKey: boolean;
ctrlKey: boolean;
preventDefault: () => void;
},
) => {
selection.setSelection(line, event.shiftKey);
event.preventDefault();
selection.setSelection(
line,
event.shiftKey,
event.metaKey || event.ctrlKey,
);
};
const handleCopySelection = (line: number) => {
selection.copySelection(line);
setShowCopyInfo(true);
};
function setRowHeight(index: number, size: number) {
@@ -97,90 +119,99 @@ export function RealLogViewer(props: RealLogViewerProps) {
}
return (
<AutoSizer>
{({ height, width }: { height?: number; width?: number }) => {
const commonProps = {
ref: setListInstance,
className: classes.log,
height: (height || 480) - HEADER_SIZE,
width: width || 640,
itemData: search.lines,
itemCount: search.lines.length,
};
<>
<AutoSizer>
{({ height, width }: { height?: number; width?: number }) => {
const commonProps = {
ref: setListInstance,
className: classes.log,
height: (height || 480) - HEADER_SIZE,
width: width || 640,
itemData: search.lines,
itemCount: search.lines.length,
};
const renderItem = ({
index,
style,
data,
}: {
index: number;
style: React.CSSProperties;
data: AnsiLine[];
}) => {
const line = data[index];
const { lineNumber } = line;
return (
<Box
style={{ ...style }}
className={classnames(classes.line, {
[classes.lineSelected]: selection.isSelected(lineNumber),
})}
>
{selection.shouldShowButton(lineNumber) && (
<IconButton
data-testid="copy-button"
size="small"
className={classes.lineCopyButton}
onClick={() => selection.copySelection()}
>
<CopyIcon fontSize="inherit" />
</IconButton>
)}
<a
role="row"
target="_self"
href={`#line-${lineNumber}`}
className={classes.lineNumber}
onClick={event => handleSelectLine(lineNumber, event)}
onKeyPress={event => handleSelectLine(lineNumber, event)}
const renderItem = ({
index,
style,
data,
}: {
index: number;
style: React.CSSProperties;
data: AnsiLine[];
}) => {
const line = data[index];
const { lineNumber } = line;
return (
<Box
style={{ ...style }}
className={classnames(classes.line, {
[classes.lineSelected]: selection.isSelected(lineNumber),
})}
>
{lineNumber}
</a>
<LogLine
setRowHeight={shouldTextWrap ? setRowHeight : undefined}
line={line}
classes={classes}
searchText={search.searchText}
highlightResultIndex={
search.resultLine === lineNumber
? search.resultLineIndex
: undefined
}
/>
{selection.shouldShowCopyButton(lineNumber) && (
<IconButton
data-testid="copy-button"
size="small"
className={classes.lineCopyButton}
onClick={() => handleCopySelection(lineNumber)}
>
<CopyIcon fontSize="inherit" />
</IconButton>
)}
<a
role="row"
target="_self"
href={`#line-${lineNumber}`}
className={classes.lineNumber}
onClick={event => handleSelectLine(lineNumber, event)}
onKeyPress={event => handleSelectLine(lineNumber, event)}
>
{lineNumber}
</a>
<LogLine
setRowHeight={shouldTextWrap ? setRowHeight : undefined}
line={line}
classes={classes}
searchText={search.searchText}
highlightResultIndex={
search.resultLine === lineNumber
? search.resultLineIndex
: undefined
}
/>
</Box>
);
};
return (
<Box style={{ width, height }} className={classes.root}>
<Box className={classes.header}>
<LogViewerControls {...search} />
</Box>
{shouldTextWrap ? (
<VariableSizeList<AnsiLine[]>
{...commonProps}
itemSize={getRowHeight}
>
{renderItem}
</VariableSizeList>
) : (
<FixedSizeList<AnsiLine[]> {...commonProps} itemSize={20}>
{renderItem}
</FixedSizeList>
)}
</Box>
);
};
return (
<Box style={{ width, height }} className={classes.root}>
<Box className={classes.header}>
<LogViewerControls {...search} />
</Box>
{shouldTextWrap ? (
<VariableSizeList<AnsiLine[]>
{...commonProps}
itemSize={getRowHeight}
>
{renderItem}
</VariableSizeList>
) : (
<FixedSizeList<AnsiLine[]> {...commonProps} itemSize={20}>
{renderItem}
</FixedSizeList>
)}
</Box>
);
}}
</AutoSizer>
}}
</AutoSizer>
<Snackbar
open={showCopyInfo}
autoHideDuration={3000}
onClose={() => setShowCopyInfo(false)}
message="Lines copied to clipboard"
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
/>
</>
);
}
@@ -89,14 +89,20 @@ export const useStyles = makeStyles(
position: 'absolute',
paddingTop: 0,
paddingBottom: 0,
'&:hover': {
color: theme.palette.linkHover,
},
},
lineNumber: {
display: 'inline-block',
textAlign: 'end',
width: 60,
paddingRight: theme.spacing(1),
marginRight: theme.spacing(1),
cursor: 'pointer',
flexShrink: 0,
color: colors.blue[300],
borderRight: `1px solid ${colors.blue[700]}`,
},
textHighlight: {
background: alpha(theme.palette.info.main, 0.15),
@@ -16,7 +16,7 @@
import { PropsWithChildren } from 'react';
import { act, renderHook } from '@testing-library/react';
import { TestApiProvider, MockErrorApi } from '@backstage/test-utils';
import { MockErrorApi, TestApiProvider } from '@backstage/test-utils';
import { errorApiRef } from '@backstage/core-plugin-api';
import { AnsiLine } from './AnsiProcessor';
import { useLogViewerSelection } from './useLogViewerSelection';
@@ -35,10 +35,26 @@ const lines = [
new AnsiLine(3, [{ text: '3', modifiers: {} }]),
new AnsiLine(4, [{ text: '4', modifiers: {} }]),
new AnsiLine(5, [{ text: '5', modifiers: {} }]),
new AnsiLine(6, [{ text: '6', modifiers: {} }]),
new AnsiLine(7, [{ text: '7', modifiers: {} }]),
];
const expectSelectedLines = (rendered: any, selectedLines: number[]) => {
expect(rendered.result.current.isSelected(1)).toBe(selectedLines.includes(1));
expect(rendered.result.current.isSelected(2)).toBe(selectedLines.includes(2));
expect(rendered.result.current.isSelected(3)).toBe(selectedLines.includes(3));
expect(rendered.result.current.isSelected(4)).toBe(selectedLines.includes(4));
expect(rendered.result.current.isSelected(5)).toBe(selectedLines.includes(5));
expect(rendered.result.current.isSelected(6)).toBe(selectedLines.includes(6));
expect(rendered.result.current.isSelected(7)).toBe(selectedLines.includes(7));
};
describe('useLogViewerSelection', () => {
it('should manage a selection', () => {
beforeEach(() => {
(copyToClipboard as jest.Mock).mockClear();
});
it('should select a new line when clicked', () => {
const rendered = renderHook(() => useLogViewerSelection(lines), {
wrapper: ({ children }: PropsWithChildren<{}>) => (
<TestApiProvider apis={[[errorApiRef, new MockErrorApi()]]}>
@@ -47,77 +63,290 @@ describe('useLogViewerSelection', () => {
),
});
expect(rendered.result.current.isSelected(1)).toBe(false);
expect(rendered.result.current.isSelected(2)).toBe(false);
expect(rendered.result.current.isSelected(3)).toBe(false);
expectSelectedLines(rendered, []);
act(() => rendered.result.current.setSelection(2, false, false));
expectSelectedLines(rendered, [2]);
act(() => rendered.result.current.setSelection(5, false, false));
expectSelectedLines(rendered, [5]);
act(() => rendered.result.current.setSelection(2, false, false));
expectSelectedLines(rendered, [2]);
});
expect(rendered.result.current.shouldShowButton(1)).toBe(false);
expect(rendered.result.current.shouldShowButton(2)).toBe(false);
expect(rendered.result.current.shouldShowButton(3)).toBe(false);
it('should deselect a selected line when clicked', () => {
const rendered = renderHook(() => useLogViewerSelection(lines), {
wrapper: ({ children }: PropsWithChildren<{}>) => (
<TestApiProvider apis={[[errorApiRef, new MockErrorApi()]]}>
{children}
</TestApiProvider>
),
});
act(() => rendered.result.current.setSelection(2, false));
expectSelectedLines(rendered, []);
act(() => rendered.result.current.setSelection(2, false, false));
expectSelectedLines(rendered, [2]);
act(() => rendered.result.current.setSelection(2, false, false));
expectSelectedLines(rendered, []);
});
expect(rendered.result.current.isSelected(1)).toBe(false);
expect(rendered.result.current.isSelected(2)).toBe(true);
expect(rendered.result.current.isSelected(3)).toBe(false);
it('should select a new line on shift+click if nothing is selected', () => {
const rendered = renderHook(() => useLogViewerSelection(lines), {
wrapper: ({ children }: PropsWithChildren<{}>) => (
<TestApiProvider apis={[[errorApiRef, new MockErrorApi()]]}>
{children}
</TestApiProvider>
),
});
expect(rendered.result.current.shouldShowButton(1)).toBe(false);
expect(rendered.result.current.shouldShowButton(2)).toBe(true);
expect(rendered.result.current.shouldShowButton(3)).toBe(false);
expectSelectedLines(rendered, []);
act(() => rendered.result.current.setSelection(2, true, false));
expectSelectedLines(rendered, [2]);
});
act(() => rendered.result.current.setSelection(3, false));
it('should deselect a single line on shift+click', () => {
const rendered = renderHook(() => useLogViewerSelection(lines), {
wrapper: ({ children }: PropsWithChildren<{}>) => (
<TestApiProvider apis={[[errorApiRef, new MockErrorApi()]]}>
{children}
</TestApiProvider>
),
});
expect(rendered.result.current.isSelected(1)).toBe(false);
expect(rendered.result.current.isSelected(2)).toBe(false);
expect(rendered.result.current.isSelected(3)).toBe(true);
expect(rendered.result.current.isSelected(4)).toBe(false);
expectSelectedLines(rendered, []);
act(() => rendered.result.current.setSelection(2, true, false));
expectSelectedLines(rendered, [2]);
act(() => rendered.result.current.setSelection(2, true, false));
expectSelectedLines(rendered, []);
});
expect(rendered.result.current.shouldShowButton(1)).toBe(false);
expect(rendered.result.current.shouldShowButton(2)).toBe(false);
expect(rendered.result.current.shouldShowButton(3)).toBe(true);
expect(rendered.result.current.shouldShowButton(4)).toBe(false);
it('should select a range below on shift+click', () => {
const rendered = renderHook(() => useLogViewerSelection(lines), {
wrapper: ({ children }: PropsWithChildren<{}>) => (
<TestApiProvider apis={[[errorApiRef, new MockErrorApi()]]}>
{children}
</TestApiProvider>
),
});
act(() => rendered.result.current.setSelection(1, true));
expectSelectedLines(rendered, []);
act(() => rendered.result.current.setSelection(2, true, false));
expectSelectedLines(rendered, [2]);
act(() => rendered.result.current.setSelection(5, true, false));
expectSelectedLines(rendered, [2, 3, 4, 5]);
});
expect(rendered.result.current.isSelected(1)).toBe(true);
expect(rendered.result.current.isSelected(2)).toBe(true);
expect(rendered.result.current.isSelected(3)).toBe(true);
expect(rendered.result.current.isSelected(4)).toBe(false);
it('should select a range above on shift+click', () => {
const rendered = renderHook(() => useLogViewerSelection(lines), {
wrapper: ({ children }: PropsWithChildren<{}>) => (
<TestApiProvider apis={[[errorApiRef, new MockErrorApi()]]}>
{children}
</TestApiProvider>
),
});
expect(rendered.result.current.shouldShowButton(1)).toBe(true);
expect(rendered.result.current.shouldShowButton(2)).toBe(false);
expect(rendered.result.current.shouldShowButton(3)).toBe(true);
expect(rendered.result.current.shouldShowButton(4)).toBe(false);
expectSelectedLines(rendered, []);
act(() => rendered.result.current.setSelection(5, true, false));
expectSelectedLines(rendered, [5]);
act(() => rendered.result.current.setSelection(2, true, false));
expectSelectedLines(rendered, [2, 3, 4, 5]);
});
act(() => rendered.result.current.setSelection(4, true));
it('should reduce a selection on shift+click', () => {
const rendered = renderHook(() => useLogViewerSelection(lines), {
wrapper: ({ children }: PropsWithChildren<{}>) => (
<TestApiProvider apis={[[errorApiRef, new MockErrorApi()]]}>
{children}
</TestApiProvider>
),
});
expect(rendered.result.current.isSelected(1)).toBe(false);
expect(rendered.result.current.isSelected(2)).toBe(false);
expect(rendered.result.current.isSelected(3)).toBe(true);
expect(rendered.result.current.isSelected(4)).toBe(true);
expect(rendered.result.current.isSelected(5)).toBe(false);
expectSelectedLines(rendered, []);
act(() => rendered.result.current.setSelection(2, true, false));
expectSelectedLines(rendered, [2]);
act(() => rendered.result.current.setSelection(7, true, false));
expectSelectedLines(rendered, [2, 3, 4, 5, 6, 7]);
act(() => rendered.result.current.setSelection(4, true, false));
expectSelectedLines(rendered, [2, 3, 4]);
act(() => rendered.result.current.setSelection(2, true, false));
expectSelectedLines(rendered, [2]);
});
expect(rendered.result.current.shouldShowButton(1)).toBe(false);
expect(rendered.result.current.shouldShowButton(2)).toBe(false);
expect(rendered.result.current.shouldShowButton(3)).toBe(true);
expect(rendered.result.current.shouldShowButton(4)).toBe(true);
expect(rendered.result.current.shouldShowButton(5)).toBe(false);
it('should add a new selection on cmd/ctrl+click', () => {
const rendered = renderHook(() => useLogViewerSelection(lines), {
wrapper: ({ children }: PropsWithChildren<{}>) => (
<TestApiProvider apis={[[errorApiRef, new MockErrorApi()]]}>
{children}
</TestApiProvider>
),
});
expectSelectedLines(rendered, []);
act(() => rendered.result.current.setSelection(2, false, true));
expectSelectedLines(rendered, [2]);
act(() => rendered.result.current.setSelection(5, false, true));
expectSelectedLines(rendered, [2, 5]);
});
it('should merge selections on cmd/ctrl+click', () => {
const rendered = renderHook(() => useLogViewerSelection(lines), {
wrapper: ({ children }: PropsWithChildren<{}>) => (
<TestApiProvider apis={[[errorApiRef, new MockErrorApi()]]}>
{children}
</TestApiProvider>
),
});
expectSelectedLines(rendered, []);
act(() => rendered.result.current.setSelection(1, false, true));
expectSelectedLines(rendered, [1]);
act(() => rendered.result.current.setSelection(3, true, false));
expectSelectedLines(rendered, [1, 2, 3]);
act(() => rendered.result.current.setSelection(5, false, true));
expectSelectedLines(rendered, [1, 2, 3, 5]);
act(() => rendered.result.current.setSelection(7, true, false));
expectSelectedLines(rendered, [1, 2, 3, 5, 6, 7]);
act(() => rendered.result.current.setSelection(4, false, true));
expectSelectedLines(rendered, [1, 2, 3, 4, 5, 6, 7]);
});
it('should split a selection on cmd/ctrl+click', () => {
const rendered = renderHook(() => useLogViewerSelection(lines), {
wrapper: ({ children }: PropsWithChildren<{}>) => (
<TestApiProvider apis={[[errorApiRef, new MockErrorApi()]]}>
{children}
</TestApiProvider>
),
});
expectSelectedLines(rendered, []);
act(() => rendered.result.current.setSelection(1, false, true));
expectSelectedLines(rendered, [1]);
act(() => rendered.result.current.setSelection(5, true, false));
expectSelectedLines(rendered, [1, 2, 3, 4, 5]);
act(() => rendered.result.current.setSelection(3, false, true));
expectSelectedLines(rendered, [1, 2, 4, 5]);
});
it('should copy a selected line', () => {
const rendered = renderHook(() => useLogViewerSelection(lines), {
wrapper: ({ children }: PropsWithChildren<{}>) => (
<TestApiProvider apis={[[errorApiRef, new MockErrorApi()]]}>
{children}
</TestApiProvider>
),
});
expectSelectedLines(rendered, []);
act(() => rendered.result.current.setSelection(2, false, false));
expectSelectedLines(rendered, [2]);
expect(copyToClipboard).not.toHaveBeenCalled();
act(() => rendered.result.current.copySelection());
expect(copyToClipboard).toHaveBeenLastCalledWith('3\n4');
act(() => rendered.result.current.copySelection(2));
expect(copyToClipboard).toHaveBeenLastCalledWith('2');
});
act(() => rendered.result.current.setSelection(2, true));
act(() => rendered.result.current.setSelection(4, true));
it('should copy a selected range', () => {
const rendered = renderHook(() => useLogViewerSelection(lines), {
wrapper: ({ children }: PropsWithChildren<{}>) => (
<TestApiProvider apis={[[errorApiRef, new MockErrorApi()]]}>
{children}
</TestApiProvider>
),
});
rendered.rerender();
act(() => rendered.result.current.copySelection());
expect(copyToClipboard).toHaveBeenCalledWith('2\n3\n4');
expectSelectedLines(rendered, []);
act(() => rendered.result.current.setSelection(2, false, false));
expectSelectedLines(rendered, [2]);
act(() => rendered.result.current.setSelection(5, true, false));
expectSelectedLines(rendered, [2, 3, 4, 5]);
act(() => rendered.result.current.setSelection(2, false));
act(() => rendered.result.current.setSelection(4, false));
act(() => rendered.result.current.setSelection(4, false));
act(() => rendered.result.current.setSelection(5, true));
act(() => rendered.result.current.copySelection());
expect(copyToClipboard).toHaveBeenCalledWith('5');
expect(copyToClipboard).not.toHaveBeenCalled();
act(() => rendered.result.current.copySelection(2));
expect(copyToClipboard).toHaveBeenCalledWith('2\n3\n4\n5');
});
it('should copy the correct selection', () => {
const rendered = renderHook(() => useLogViewerSelection(lines), {
wrapper: ({ children }: PropsWithChildren<{}>) => (
<TestApiProvider apis={[[errorApiRef, new MockErrorApi()]]}>
{children}
</TestApiProvider>
),
});
rendered.rerender();
expectSelectedLines(rendered, []);
act(() => rendered.result.current.setSelection(2, false, false));
expectSelectedLines(rendered, [2]);
act(() => rendered.result.current.setSelection(5, true, false));
expectSelectedLines(rendered, [2, 3, 4, 5]);
act(() => rendered.result.current.setSelection(7, false, true));
expectSelectedLines(rendered, [2, 3, 4, 5, 7]);
expect(copyToClipboard).not.toHaveBeenCalled();
act(() => rendered.result.current.copySelection(2));
expect(copyToClipboard).toHaveBeenCalledWith('2\n3\n4\n5');
act(() => rendered.result.current.copySelection(7));
expect(copyToClipboard).toHaveBeenCalledWith('7');
});
it('should add a single line selection to the hash', () => {
const rendered = renderHook(() => useLogViewerSelection(lines), {
wrapper: ({ children }: PropsWithChildren<{}>) => (
<TestApiProvider apis={[[errorApiRef, new MockErrorApi()]]}>
{children}
</TestApiProvider>
),
});
rendered.rerender();
expectSelectedLines(rendered, []);
act(() => rendered.result.current.setSelection(2, false, false));
expectSelectedLines(rendered, [2]);
expect(rendered.result.current.getHash()).toBe('#lines-2');
});
it('should add a range selection to the hash', () => {
const rendered = renderHook(() => useLogViewerSelection(lines), {
wrapper: ({ children }: PropsWithChildren<{}>) => (
<TestApiProvider apis={[[errorApiRef, new MockErrorApi()]]}>
{children}
</TestApiProvider>
),
});
rendered.rerender();
expectSelectedLines(rendered, []);
act(() => rendered.result.current.setSelection(2, false, false));
expectSelectedLines(rendered, [2]);
act(() => rendered.result.current.setSelection(5, true, false));
expectSelectedLines(rendered, [2, 3, 4, 5]);
expect(rendered.result.current.getHash()).toBe('#lines-2-5');
});
it('should add multiple selections to the hash', () => {
const rendered = renderHook(() => useLogViewerSelection(lines), {
wrapper: ({ children }: PropsWithChildren<{}>) => (
<TestApiProvider apis={[[errorApiRef, new MockErrorApi()]]}>
{children}
</TestApiProvider>
),
});
rendered.rerender();
expectSelectedLines(rendered, []);
act(() => rendered.result.current.setSelection(3, false, false));
expectSelectedLines(rendered, [3]);
act(() => rendered.result.current.setSelection(5, true, false));
expectSelectedLines(rendered, [3, 4, 5]);
act(() => rendered.result.current.setSelection(1, false, true));
expectSelectedLines(rendered, [1, 3, 4, 5]);
act(() => rendered.result.current.setSelection(7, false, true));
expectSelectedLines(rendered, [1, 3, 4, 5, 7]);
expect(rendered.result.current.getHash()).toBe('#lines-3-5,1,7');
});
});
@@ -19,11 +19,14 @@ import { useEffect, useState } from 'react';
import useCopyToClipboard from 'react-use/esm/useCopyToClipboard';
import { AnsiLine } from './AnsiProcessor';
type Selection = {
start: number;
end: number;
};
export function useLogViewerSelection(lines: AnsiLine[]) {
const errorApi = useApi(errorApiRef);
const [sel, setSelection] = useState<{ start: number; end: number }>();
const start = sel ? Math.min(sel.start, sel.end) : undefined;
const end = sel ? Math.max(sel.start, sel.end) : undefined;
const [selections, setSelections] = useState<Selection[]>([]);
const [{ error }, copyToClipboard] = useCopyToClipboard();
@@ -33,38 +36,202 @@ export function useLogViewerSelection(lines: AnsiLine[]) {
}
}, [error, errorApi]);
const findClosestSelection = (
allSelections: Selection[],
line: number,
): Selection | undefined => {
if (selections.length === 0) {
return undefined;
}
let minDistance = Number.MAX_SAFE_INTEGER;
let closestSelection: Selection | undefined = undefined;
allSelections.forEach(s => {
const distance = Math.min(
Math.abs(s.start - line),
Math.abs(s.end - line),
);
if (distance < minDistance) {
minDistance = distance;
closestSelection = s;
}
});
return closestSelection;
};
const mergeNeighbouringSelections = (
allSelections: Selection[],
line: number,
): Selection[] => {
// Merge selections if they're next to each other
const neighboringSelections = allSelections.filter(
s => s.start - 1 === line || s.end + 1 === line,
);
if (neighboringSelections.length === 0) {
return allSelections;
}
const newSelection = {
start: Math.min(line, ...neighboringSelections.map(s => s.start)),
end: Math.max(line, ...neighboringSelections.map(s => s.end)),
};
return [
...allSelections.filter(
s =>
!neighboringSelections.includes(s) &&
!(s.start === line && s.end === line),
),
newSelection,
];
};
return {
shouldShowButton(line: number) {
return start === line || end === line;
shouldShowCopyButton(line: number) {
// show copy button at the beginning of each selection
return selections.some(s => s.start === line);
},
isSelected(line: number) {
if (!sel) {
if (!selections) {
return false;
}
return start! <= line && line <= end!;
// check if line is in any selection range
return selections.some(
s => s.start <= line && (s.end ?? s.start) >= line,
);
},
setSelection(line: number, add: boolean) {
if (add) {
setSelection(s =>
s ? { start: s.start, end: line } : { start: line, end: line },
setSelection(line: number, addRange: boolean, addNewSelection: boolean) {
setSelections(currentSelections => {
const clickedSelection = currentSelections.find(
s => s.start <= line && s.end >= line,
);
} else {
setSelection(s =>
s?.start === line && s?.end === line
? undefined
: { start: line, end: line },
const otherSelections = currentSelections.filter(
s => s !== clickedSelection,
);
}
if (!addRange && !addNewSelection) {
// Normal click -> select only this line if nothing or multiple lines are selected
if (
!clickedSelection ||
clickedSelection.start !== clickedSelection.end
) {
return [{ start: line, end: line }];
}
// Clear selection if single line is selected
return [];
}
if (addRange) {
// Shift+click -> extend/reduce selection
if (currentSelections.length === 0) {
// No existing selection -> create new selection
return [{ start: line, end: line }];
}
if (clickedSelection) {
// Clicked inside an existing selection -> reduce selection
if (clickedSelection.start === clickedSelection.end) {
// Single line selection -> remove it
return otherSelections;
}
// Reduce selection
return [
...otherSelections,
{ start: clickedSelection.start, end: line },
];
}
// Extend the closest selection to the new line
const closestSelection = findClosestSelection(
currentSelections,
line,
);
if (!closestSelection) {
// Can't actually happen
return currentSelections;
}
if (closestSelection.start < line) {
// Add lines before the selection
return mergeNeighbouringSelections(
[
...otherSelections.filter(s => s !== closestSelection),
{ start: closestSelection.start, end: line },
],
line,
);
}
// Add lines after the selection
return mergeNeighbouringSelections(
[
...otherSelections.filter(s => s !== closestSelection),
{ start: line, end: closestSelection!.end },
],
line,
);
}
if (addNewSelection) {
// Ctrl/Cmd+click -> add new selection
if (!clickedSelection) {
// Just add new selection
return mergeNeighbouringSelections(
[...currentSelections, { start: line, end: line }],
line,
);
}
if (clickedSelection.start === clickedSelection.end) {
// Single line selection -> remove it
return otherSelections;
}
// Multi line selection -> split it
return [
...otherSelections,
...(clickedSelection.start < line
? [{ start: clickedSelection.start, end: line - 1 }]
: []),
...(clickedSelection.end > line
? [{ start: line + 1, end: clickedSelection.end }]
: []),
];
}
return [];
});
},
copySelection() {
if (sel) {
const copyText = lines
.slice(Math.min(sel.start, sel.end) - 1, Math.max(sel.start, sel.end))
.map(l => l.chunks.map(c => c.text).join(''))
.join('\n');
copyToClipboard(copyText);
setSelection(undefined);
copySelection(line: number) {
const selection = selections.find(s => s.start === line);
if (!selection) {
return;
}
const copyText = lines
.slice(selection.start - 1, selection.end)
.map(l => l.chunks.map(c => c.text).join(''))
.join('\n');
copyToClipboard(copyText);
},
getHash() {
if (selections.length === 0) {
return '';
}
const parts = selections.map(s => {
if (s.start === s.end) {
return `${s.start}`;
}
return `${s.start}-${s.end}`;
});
return `#lines-${parts.join(',')}`;
},
selectAll(hash: string) {
const match = hash.match(/#lines-([\d,-]+)/);
const s: Selection[] = [];
if (match) {
const ranges = match[1].split(',');
ranges.forEach(r => {
const [start, end] = r.split('-').map(Number);
s.push({ start, end: end ?? start });
});
}
setSelections(s);
},
};
}