Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Clean up copy/pasting of tabular content in EuiDataGrid and EuiBasic/InMemoryTable #8019

Merged
merged 14 commits into from
Sep 23, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/eui/src/services/copy/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,7 @@
*/

export { copyToClipboard } from './copy_to_clipboard';
export {
tabularCopyMarkers,
OverrideCopiedTabularContent,
} from './tabular_copy';
91 changes: 91 additions & 0 deletions packages/eui/src/services/copy/tabular_copy.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/

import { onTabularCopy, CHARS } from './tabular_copy';

describe('onTabularCopy', () => {
const mockSetData = jest.fn();
const mockEvent = {
clipboardData: { setData: mockSetData },
preventDefault: () => {},
} as unknown as ClipboardEvent;

const mockSelectedText = jest.fn(() => '');
Object.defineProperty(window, 'getSelection', {
writable: true,
value: jest.fn().mockImplementation(() => ({
toString: mockSelectedText,
})),
});

beforeEach(() => {
jest.clearAllMocks();
mockSelectedText.mockReturnValue('');
});

it('does nothing if no special copy characters are in the clipboard', () => {
mockSelectedText.mockReturnValue('hello\nworld\t');
onTabularCopy(mockEvent);
expect(mockSetData).not.toHaveBeenCalled();
});

it('strips all newlines and replaces the newline character with our newlines', () => {
mockSelectedText.mockReturnValue('hello\nworld\r\nand↵goodbye world');
onTabularCopy(mockEvent);
expect(mockSetData).toHaveBeenCalledWith(
'text/plain',
'helloworldand\ngoodbye world'
);
});

it('strips all horizontal tabs and replaces the tab character with our tabs', () => {
mockSelectedText.mockReturnValue('hello\tworld↦goodbye\tworld');
onTabularCopy(mockEvent);
expect(mockSetData).toHaveBeenCalledWith(
'text/plain',
'helloworld\tgoodbyeworld'
);
});

it('strips out any text between the no-copy characters', () => {
mockSelectedText.mockReturnValue(
`${CHARS.NO_COPY_BOUND}some of${CHARS.NO_COPY_BOUND} this text should ${CHARS.NO_COPY_BOUND}not${CHARS.NO_COPY_BOUND} appear`
);
onTabularCopy(mockEvent);
expect(mockSetData).toHaveBeenCalledWith(
'text/plain',
' this text should appear'
);
});

it('does not clean text outside of specified bounds', () => {
mockSelectedText.mockReturnValue(`
this
is
not
cleaned
${CHARS.TABULAR_CONTENT_BOUND}↵this\r\nis\ncleaned${CHARS.TABULAR_CONTENT_BOUND}
also\tnot\tcleaned
${CHARS.TABULAR_CONTENT_BOUND}↦also\tcleaned${CHARS.TABULAR_CONTENT_BOUND}
`);
onTabularCopy(mockEvent);
expect(mockSetData).toHaveBeenCalledWith(
'text/plain',
`
this
is
not
cleaned

thisiscleaned
also not cleaned
alsocleaned
`
);
});
});
21 changes: 21 additions & 0 deletions packages/eui/src/services/copy/tabular_copy.testenv.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/

import React, { PropsWithChildren } from 'react';

// Don't render these characters in Jest snapshots. I don't want to deal with the Kibana tests 🫠
export const tabularCopyMarkers = {
hiddenTab: <></>,
hiddenNewline: <></>,
hiddenWrapperBoundary: <></>,
ariaHiddenNoCopyBoundary: <></>,
};

// Don't bother initializing in Kibana Jest either
export const OverrideCopiedTabularContent = ({ children }: PropsWithChildren) =>
children;
108 changes: 108 additions & 0 deletions packages/eui/src/services/copy/tabular_copy.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/

import React, { PropsWithChildren, useEffect } from 'react';

/**
* Clipboard text cleaning logic
*/

// Special visually hidden unicode characters that we use to manually clean content
// and force our own newlines/horizontal tabs
export const CHARS = {
NEWLINE: '↵',
TAB: '↦',
TABULAR_CONTENT_BOUND: '⁣', // U+2063 - invisible separator
NO_COPY_BOUND: '⁢', // U+2062 - invisible times
};
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd really appreciate people's thoughts on the (semi-random) characters I've chosen here and if we think they're unlikely enough to be used in consumer content to feel safe using them here!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm wondering if the invisible times might be used in a grid somehow as its usage seems to be in maths context.

The invisible times codepoint is used in mathematical type-setting to indicate the multiplication of two terms without a visible multiplication operator, e.g. when type-setting 2x (the multiplication of the number 2 and the variable x), the invisible times codepoint can be inserted in-between: 2 <U+2062> x.

And the invisible seperator ("invisible comma") is apparently commonly used. Question would be if that's a case for a datagrid though.

Do we need to use invisible characters here?
If not, I found this "Angle with Down Zig-zag Arrow" character of the "Misc technical" block which seems to have no meaning and hence hopefully is not commonly used? 🤔

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm good with changing it to whatever! 👍 No strong feelings either way. I was also thinking maybe these scissors characters?

✁ UPPER BLADE SCISSORS
✃ LOWER BLADE SCISSORS

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I'm fine with kind of whatever too, in the end the important thing is it's not commonly used.
We could try the scissors and worst case we update if issues are reported.
... I just had another thought: How about combining 2 random characters? That'll reduce the likelyhood of them being used for sure?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can use two random characters for NO_COPY_BOUND fairly easily, but I'm a little more hesitant for TABULAR_CONTENT_BOUND, as that complicates the .split() logic I'm using & would possibly require a regex at that point. Do you have any suggestions for characters? 👀

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mgadewoll I misread your comment - I thought you meant using two different characters for the start & end bounds! Using two characters does seem super smart - I've made that tweak here (9a22861) while trying to keep the symbols still fairly relevant to the intent. Let me know if it looks good to you!

// This regex finds all content between two bound characters
const noCopyBoundsRegex = new RegExp(
`${CHARS.NO_COPY_BOUND}[^${CHARS.NO_COPY_BOUND}]*${CHARS.NO_COPY_BOUND}`,
'gs'
);

const hasCharsToReplace = (text: string) => {
for (const char of Object.values(CHARS)) {
if (text.indexOf(char) >= 0) return true;
}
return false;
};

// Strip all existing newlines and replace our special hidden characters
// with the desired spacing needed to paste cleanly into a spreadsheet
export const onTabularCopy = (event: ClipboardEvent | React.ClipboardEvent) => {
if (!event.clipboardData) return;

const selectedText = window.getSelection()?.toString();
mgadewoll marked this conversation as resolved.
Show resolved Hide resolved
if (!selectedText || !hasCharsToReplace(selectedText)) return;

const amendedText = selectedText
.split(CHARS.TABULAR_CONTENT_BOUND)
.map((text) => {
return hasCharsToReplace(text)
? text
.replace(/\r?\n/g, '') // remove all other newlines generated by content or block display
.replaceAll(CHARS.NEWLINE, '\n') // insert newline for each table/grid row
.replace(/\t/g, '') // remove tabs generated by content or automatically by <td> elements
.replaceAll(CHARS.TAB, '\u0009') // insert horizontal tab for each table/grid cell
.replace(noCopyBoundsRegex, '') // remove text that should not be copied (e.g. screen reader instructions)
: text;
})
.join('');

event.clipboardData.setData('text/plain', amendedText);
event.preventDefault();
};

/**
* JSX utils for rendering the hidden marker characters
*/

const VisuallyHide = ({ children }: PropsWithChildren) => (
// Hides the characters to both sighted user and screen readers
// Sadly, we can't use `hidden` as that hides the chars from the clipboard as well
<span className="euiScreenReaderOnly" aria-hidden data-tabular-copy-marker>
{children}
</span>
);

export const tabularCopyMarkers = {
hiddenTab: <VisuallyHide>{CHARS.TAB}</VisuallyHide>,
hiddenNewline: <VisuallyHide>{CHARS.NEWLINE}</VisuallyHide>,
hiddenWrapperBoundary: (
<VisuallyHide>{CHARS.TABULAR_CONTENT_BOUND}</VisuallyHide>
),
// Should be used within existing <EuiScreenReaderOnly>, ideally to avoid generating extra DOM
ariaHiddenNoCopyBoundary: <span aria-hidden>{CHARS.NO_COPY_BOUND}</span>,
};

/**
* Wrapper setup around table/grid tabular content we want to override/clean up on copy
*/

export const OverrideCopiedTabularContent = ({
children,
}: PropsWithChildren) => {
useEffect(() => {
// Chrome and webkit browsers work perfectly when passing `onTabularCopy` to a React
// `onCopy` prop, but sadly Firefox does not if copying more than just the table/grid
// (e.g. Ctrl+A). So we have to set up a global window event listener
window.addEventListener('copy', onTabularCopy);
// Note: Since onCopy is static, we don't have to worry about duplicate
// event listeners - it's automatically handled by the browser. See:
// https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#Multiple_identical_event_listeners
}, []);

return (
<>
{tabularCopyMarkers.hiddenWrapperBoundary}
{children}
{tabularCopyMarkers.hiddenWrapperBoundary}
</>
);
};