[TechDocs Addons] Give Feedback Addon (#10733)

* give feedback addon implementation

Co-authored-by: Camila Belo <camilaibs@gmail.com>
Co-authored-by: Anders Näsman <andersn@spotify.com>
Signed-off-by: Emma Indal <emma.indahl@gmail.com>

* export replaceUrlType from @backstage/integration-reacte

Co-authored-by: Camila Belo <camilaibs@gmail.com>
Co-authored-by: Anders Näsman <andersn@spotify.com>
Signed-off-by: Emma Indal <emma.indahl@gmail.com>

* export give feedback addon

Signed-off-by: Emma Indal <emma.indahl@gmail.com>

* add hooks used for give feedback addon

Co-authored-by: Camila Belo <camilaibs@gmail.com>
Co-authored-by: Anders Näsman <andersn@spotify.com>
Signed-off-by: Emma Indal <emma.indahl@gmail.com>

* replaceUrlType -> replaceGitLabUrlType

Signed-off-by: Emma Indal <emma.indahl@gmail.com>

* clarify template + template builder types

Signed-off-by: Emma Indal <emma.indahl@gmail.com>

* feedback fixups

Signed-off-by: Emma Indal <emma.indahl@gmail.com>

Co-authored-by: Camila Belo <camilaibs@gmail.com>
Co-authored-by: Anders Näsman <andersn@spotify.com>
This commit is contained in:
Emma Indal
2022-04-11 08:55:54 +02:00
committed by GitHub
parent 88fd7d1e55
commit d26e1b0146
19 changed files with 670 additions and 11 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/integration': patch
---
replaceGitLabUrlType exported from package
+6
View File
@@ -416,6 +416,12 @@ export function replaceGitHubUrlType(
type: 'blob' | 'tree' | 'edit',
): string;
// @public
export function replaceGitLabUrlType(
url: string,
type: 'blob' | 'tree' | 'edit',
): string;
// @public
export interface ScmIntegration {
resolveEditUrl(url: string): string;
@@ -15,7 +15,7 @@
*/
import { ConfigReader } from '@backstage/config';
import { GitLabIntegration, replaceUrlType } from './GitLabIntegration';
import { GitLabIntegration, replaceGitLabUrlType } from './GitLabIntegration';
describe('GitLabIntegration', () => {
it('has a working factory', () => {
@@ -55,28 +55,28 @@ describe('GitLabIntegration', () => {
});
});
describe('replaceUrlType', () => {
describe('replaceGitLabUrlType', () => {
it('should replace with expected type', () => {
expect(
replaceUrlType(
replaceGitLabUrlType(
'https://gitlab.com/my-org/my-project/-/blob/develop/README.md',
'edit',
),
).toBe('https://gitlab.com/my-org/my-project/-/edit/develop/README.md');
expect(
replaceUrlType(
replaceGitLabUrlType(
'https://gitlab.com/webmodules/blob/-/blob/develop/test',
'tree',
),
).toBe('https://gitlab.com/webmodules/blob/-/tree/develop/test');
expect(
replaceUrlType(
replaceGitLabUrlType(
'https://gitlab.com/blob/blob/-/blob/develop/test',
'tree',
),
).toBe('https://gitlab.com/blob/blob/-/tree/develop/test');
expect(
replaceUrlType(
replaceGitLabUrlType(
'https://gitlab.com/blob/blob/-/edit/develop/README.md',
'tree',
),
@@ -60,11 +60,18 @@ export class GitLabIntegration implements ScmIntegration {
}
resolveEditUrl(url: string): string {
return replaceUrlType(url, 'edit');
return replaceGitLabUrlType(url, 'edit');
}
}
export function replaceUrlType(
/**
* Takes a GitLab URL and replaces the type part (blob, tree etc).
*
* @param url - The original URL
* @param type - The desired type, e.g. 'blob', 'tree', 'edit'
* @public
*/
export function replaceGitLabUrlType(
url: string,
type: 'blob' | 'tree' | 'edit',
): string {
+1 -1
View File
@@ -20,4 +20,4 @@ export {
} from './config';
export type { GitLabIntegrationConfig } from './config';
export { getGitLabFileFetchUrl, getGitLabRequestOptions } from './core';
export { GitLabIntegration } from './GitLabIntegration';
export { GitLabIntegration, replaceGitLabUrlType } from './GitLabIntegration';
+21
View File
@@ -8,6 +8,7 @@
import { ApiRef } from '@backstage/core-plugin-api';
import { AsyncState } from 'react-use/lib/useAsync';
import { BackstagePlugin } from '@backstage/core-plugin-api';
import { ComponentType } from 'react';
import { CompoundEntityRef } from '@backstage/catalog-model';
import { Config } from '@backstage/config';
import { CSSProperties } from '@material-ui/styles';
@@ -154,6 +155,26 @@ export const EntityTechdocsContent: (props: {
children?: ReactNode;
}) => JSX.Element;
// @public
export const GiveFeedbackAddon: ComponentType<GiveFeedbackProps>;
// @public (undocumented)
export type GiveFeedbackProps = {
debounceTime?: number;
templateBuilder?: GiveFeedbackTemplateBuilder;
};
// @public (undocumented)
export type GiveFeedbackTemplate = {
title: string;
body: string;
};
// @public (undocumented)
export type GiveFeedbackTemplateBuilder = (
selection: Selection,
) => GiveFeedbackTemplate;
// @public
export const isTechDocsAvailable: (entity: Entity) => boolean;
+1 -1
View File
@@ -53,7 +53,7 @@
"@material-ui/lab": "4.0.0-alpha.57",
"@material-ui/styles": "^4.10.0",
"dompurify": "^2.2.9",
"event-source-polyfill": "1.0.25",
"event-source-polyfill": "^1.0.25",
"git-url-parse": "^11.6.0",
"jss": "~10.8.2",
"lodash": "^4.17.21",
@@ -0,0 +1,84 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { analyticsApiRef } from '@backstage/core-plugin-api';
import {
MockAnalyticsApi,
TestApiProvider,
wrapInTestApp,
} from '@backstage/test-utils';
import { FeedbackLink } from './FeedbackLink';
const defaultProps = {
repository: {
type: 'github',
name: 'backstage',
owner: 'backstage',
protocol: 'https',
resource: 'github.com',
},
template: {
title: 'Documentation feedback',
body: '## Documentation Feedback 📝',
},
};
describe('FeedbackLink', () => {
const apiSpy = new MockAnalyticsApi();
it('Should open new issue tab', () => {
render(
wrapInTestApp(
<TestApiProvider apis={[[analyticsApiRef, apiSpy]]}>
<FeedbackLink {...defaultProps} />
</TestApiProvider>,
),
);
const link = screen.getByText(/Open new Github issue/);
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute('target', '_blank');
const encodedTitle = encodeURIComponent(defaultProps.template.title);
const encodedBody = encodeURIComponent(defaultProps.template.body);
expect(link).toHaveAttribute(
'href',
`https://github.com/backstage/backstage/issues/new?title=${encodedTitle}&body=${encodedBody}`,
);
});
it('Should track click events', async () => {
render(
wrapInTestApp(
<TestApiProvider apis={[[analyticsApiRef, apiSpy]]}>
<FeedbackLink {...defaultProps} />
</TestApiProvider>,
),
);
fireEvent.click(screen.getByText(/Open new Github issue/));
await waitFor(() => {
expect(apiSpy.getEvents()[0]).toMatchObject({
action: 'click',
subject: 'Open new Github issue',
});
});
});
});
@@ -0,0 +1,83 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { makeStyles } from '@material-ui/core';
import BugReportIcon from '@material-ui/icons/BugReport';
import { Link, GitHubIcon } from '@backstage/core-components';
import { GiveFeedbackTemplate, Repository } from './types';
const useStyles = makeStyles(theme => ({
root: {
display: 'grid',
gridGap: theme.spacing(1),
gridAutoFlow: 'column',
justifyContent: 'center',
alignItems: 'center',
color: theme.palette.common.black,
fontSize: theme.typography.button.fontSize,
},
}));
type FeedbackLinkProps = {
template: GiveFeedbackTemplate;
repository: Repository;
};
const getIcon = ({ type }: Repository) => {
if (type === 'github') {
return GitHubIcon;
}
return BugReportIcon;
};
const getName = ({ type }: Repository) => {
if (type === 'github') {
return 'Github';
}
return 'Gitlab';
};
const getUrl = (repository: Repository, template: GiveFeedbackTemplate) => {
const { title, body } = template;
const encodedTitle = encodeURIComponent(title);
const encodedBody = encodeURIComponent(body);
const { protocol, resource, owner, name, type } = repository;
const encodedOwner = encodeURIComponent(owner);
const encodedName = encodeURIComponent(name);
const url = `${protocol}://${resource}/${encodedOwner}/${encodedName}`;
if (type === 'github') {
return `${url}/issues/new?title=${encodedTitle}&body=${encodedBody}`;
}
return `${url}/issues/new?[title]=${encodedTitle}&[body]=${encodedBody}`;
};
export const FeedbackLink = ({ template, repository }: FeedbackLinkProps) => {
const classes = useStyles();
const Icon = getIcon(repository);
const url = getUrl(repository, template);
return (
<Link className={classes.root} to={url} target="_blank">
<Icon /> Open new {getName(repository)} issue
</Link>
);
};
@@ -0,0 +1,132 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useState, useEffect } from 'react';
import { makeStyles, Portal, Paper } from '@material-ui/core';
import { useGitTemplate, useGitRepository } from './hooks';
import { GiveFeedbackTemplateBuilder } from './types';
import {
PAGE_MAIN_CONTENT_SELECTOR,
PAGE_FEEDBACK_LINK_SELECTOR,
ADDON_FEEDBACK_CONTAINER_ID,
ADDON_FEEDBACK_CONTAINER_SELECTOR,
} from './constants';
import { FeedbackLink } from './FeedbackLink';
import {
useShadowRootElements,
useShadowRootSelection,
} from '../../reader/components/TechDocsReaderPage';
const useStyles = makeStyles(theme => ({
root: {
transform: 'translate(-100%, -100%)',
position: 'absolute',
padding: theme.spacing(1),
zIndex: theme.zIndex.tooltip,
background: theme.palette.common.white,
},
}));
type Style = {
top: string;
left: string;
};
/**
* @public
*/
export type GiveFeedbackProps = {
debounceTime?: number;
templateBuilder?: GiveFeedbackTemplateBuilder;
};
/**
* Show give feedback button when text is highlighted
*/
export const GiveFeedback = ({
debounceTime = 500,
templateBuilder: buildTemplate,
}: GiveFeedbackProps) => {
const classes = useStyles();
const [style, setStyle] = useState<Style>();
const repository = useGitRepository();
const defaultTemplate = useGitTemplate(debounceTime);
const selection = useShadowRootSelection(debounceTime);
const [mainContent, feedbackLink] = useShadowRootElements([
PAGE_MAIN_CONTENT_SELECTOR,
PAGE_FEEDBACK_LINK_SELECTOR,
]);
let [feedbackContainer] = useShadowRootElements([
ADDON_FEEDBACK_CONTAINER_SELECTOR,
]);
if (feedbackLink) {
feedbackLink.style.display = 'none';
}
// calculates the position of the selected text to be able to set the position of the addon
useEffect(() => {
if (
// todo(backstage/techdocs-core) handle non-repo rendering
!repository ||
!selection ||
!selection.containsNode(mainContent!, true) ||
selection?.containsNode(feedbackContainer!, true)
) {
return;
}
const mainContentPosition = mainContent!.getBoundingClientRect();
const selectionPosition = selection.getRangeAt(0).getBoundingClientRect();
setStyle({
top: `${selectionPosition.top - mainContentPosition.top - 16}px`,
left: `${selectionPosition.left + selectionPosition.width / 2}px`,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selection, mainContent, feedbackContainer]);
if (!selection || !repository) return null;
if (!feedbackContainer) {
feedbackContainer = document.createElement('div');
feedbackContainer.setAttribute('id', ADDON_FEEDBACK_CONTAINER_ID);
mainContent!.prepend(feedbackContainer);
}
return (
<Portal container={feedbackContainer}>
<Paper
data-testid="give-feedback-addon"
className={classes.root}
style={style}
>
<FeedbackLink
repository={repository}
template={buildTemplate ? buildTemplate(selection) : defaultTemplate}
/>
</Paper>
</Portal>
);
};
@@ -0,0 +1,22 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export const ADDON_FEEDBACK_CONTAINER_ID = 'techdocs-give-feedback';
export const ADDON_FEEDBACK_CONTAINER_SELECTOR = `#${ADDON_FEEDBACK_CONTAINER_ID}`;
export const PAGE_EDIT_LINK_SELECTOR = '[title^="Edit this page"]';
export const PAGE_FEEDBACK_LINK_SELECTOR = '[title^="Leave feedback for"]';
export const PAGE_MAIN_CONTENT_SELECTOR =
'[data-md-component="main"] .md-content';
@@ -0,0 +1,96 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import parseGitUrl from 'git-url-parse';
import { useApi } from '@backstage/core-plugin-api';
import {
replaceGitHubUrlType,
replaceGitLabUrlType,
} from '@backstage/integration';
import { scmIntegrationsApiRef } from '@backstage/integration-react';
import {
useShadowRootElements,
useShadowRootSelection,
} from '../../reader/components/TechDocsReaderPage';
import { PAGE_EDIT_LINK_SELECTOR } from './constants';
const resolveBlobUrl = (url: string, type: string) => {
if (type === 'github') {
return replaceGitHubUrlType(url, 'blob');
}
return replaceGitLabUrlType(url, 'blob');
};
export const getTitle = (selection: Selection) => {
const text = selection.toString().substring(0, 70);
const ellipsis = text.length === 70 ? '...' : '';
return `Documentation feedback: ${text}${ellipsis}`;
};
export const getBody = (selection: Selection, markdownUrl: string) => {
const title = '## Documentation Feedback 📝';
const subheading = '#### The highlighted text:';
const commentHeading = '#### The comment on the text:';
const commentPlaceholder = '_>replace this line with your comment<_';
const highlightedTextAsQuote = selection
.toString()
.trim()
.split('\n')
.map(line => `> ${line.trim()}`)
.join('\n');
const facts = [
`Backstage URL: <${window.location.href}> \nMarkdown URL: <${markdownUrl}>`,
];
return `${title}\n\n ${subheading} \n\n ${highlightedTextAsQuote}\n\n ${commentHeading} \n ${commentPlaceholder}\n\n ___\n${facts}`;
};
export const useGitTemplate = (debounceTime?: number) => {
const initialTemplate = { title: '', body: '' };
const selection = useShadowRootSelection(debounceTime);
const [editLink] = useShadowRootElements([PAGE_EDIT_LINK_SELECTOR]);
const url = (editLink as HTMLAnchorElement)?.href ?? '';
const scmIntegrationsApi = useApi(scmIntegrationsApiRef);
if (!selection || !url) return initialTemplate;
const type = scmIntegrationsApi.byUrl(url)?.type;
if (!type) return initialTemplate;
return {
title: getTitle(selection),
body: getBody(selection, resolveBlobUrl(url, type)),
};
};
export const useGitRepository = () => {
const scmIntegrationsApi = useApi(scmIntegrationsApiRef);
const [editLink] = useShadowRootElements([PAGE_EDIT_LINK_SELECTOR]);
const url = (editLink as HTMLAnchorElement)?.href ?? '';
if (!url) return null;
const type = scmIntegrationsApi.byUrl(url)?.type;
if (!type) return null;
return { ...parseGitUrl(resolveBlobUrl(url, type)), type };
};
@@ -0,0 +1,22 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { GiveFeedback } from './GiveFeedback';
export type { GiveFeedbackProps } from './GiveFeedback';
export type {
GiveFeedbackTemplate,
GiveFeedbackTemplateBuilder,
} from './types';
@@ -0,0 +1,38 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @public
*/
export type GiveFeedbackTemplate = {
title: string;
body: string;
};
/**
* @public
*/
export type GiveFeedbackTemplateBuilder = (
selection: Selection,
) => GiveFeedbackTemplate;
export type Repository = {
type: string;
name: string;
owner: string;
protocol: string;
resource: string;
};
+21
View File
@@ -0,0 +1,21 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export type {
GiveFeedbackProps,
GiveFeedbackTemplate,
GiveFeedbackTemplateBuilder,
} from './GiveFeedback';
+2
View File
@@ -24,6 +24,7 @@ export * from './types';
export * from './api';
export * from './client';
export * from './reader';
export * from './addons';
export * from './search';
export * from './home';
export {
@@ -34,5 +35,6 @@ export {
TechDocsReaderPage,
techdocsPlugin as plugin,
techdocsPlugin,
GiveFeedbackAddon,
} from './plugin';
export * from './Router';
+19
View File
@@ -30,6 +30,11 @@ import {
fetchApiRef,
identityApiRef,
} from '@backstage/core-plugin-api';
import { GiveFeedback, GiveFeedbackProps } from './addons/GiveFeedback';
import {
createTechDocsAddon,
TechDocsAddonLocations,
} from '@backstage/techdocs-addons';
/**
* The Backstage plugin that renders technical documentation for your components
@@ -150,3 +155,17 @@ export const TechDocsReaderPage = techdocsPlugin.provide(
mountPoint: rootDocsRouteRef,
}),
);
/**
* TechDocs addon that lets you select text and open GitHub/Gitlab issues
*
* @public
*/
export const GiveFeedbackAddon = techdocsPlugin.provide(
createTechDocsAddon<GiveFeedbackProps>({
name: 'GiveFeedback',
location: TechDocsAddonLocations.CONTENT,
component: GiveFeedback,
}),
);
@@ -14,13 +14,29 @@
* limitations under the License.
*/
import { useShadowRoot, useShadowRootElements } from './hooks';
import {
useShadowRoot,
useShadowRootElements,
useShadowRootSelection,
} from './hooks';
import { renderHook } from '@testing-library/react-hooks';
import { fireEvent, waitFor } from '@testing-library/react';
const fireSelectionChangeEvent = (window: Window) => {
const selectionChangeEvent = window.document.createEvent('Event');
selectionChangeEvent.initEvent('selectionchange', true, true);
window.document.addEventListener('selectionchange', () => {}, false);
fireEvent(window.document, selectionChangeEvent);
};
const getSelection = jest.fn();
const mockShadowRoot = () => {
const div = document.createElement('div');
const shadowRoot = div.attachShadow({ mode: 'open' });
shadowRoot.innerHTML = '<h1>Shadow DOM Mock</h1>';
(shadowRoot as ShadowRoot & Pick<Document, 'getSelection'>).getSelection =
getSelection;
return shadowRoot;
};
@@ -32,6 +48,28 @@ jest.mock('./context', () => {
};
});
const selection = {
type: 'Range',
rangeCount: 1,
isCollapsed: true,
getRangeAt: () => ({
startContainer: 'this is a sentence',
endContainer: 'this is a sentence',
startOffset: 1,
endOffset: 3,
getBoundingClientRect: () => ({
right: 100,
top: 100,
width: 100,
height: 100,
}),
}),
toString: () => 'his ',
containsNode: () => true,
} as unknown as Selection;
getSelection.mockReturnValue(selection);
describe('hooks', () => {
describe('useShadowRoot', () => {
it('should return shadow root', async () => {
@@ -48,4 +86,18 @@ describe('hooks', () => {
expect(result.current).toHaveLength(1);
});
});
describe('useShadowRootSelection', () => {
it('should return shadow root selection', async () => {
const { result } = renderHook(() => useShadowRootSelection(0));
expect(result.current).toBeNull();
fireSelectionChangeEvent(window);
await waitFor(() => {
expect(result.current?.toString()).toEqual('his ');
});
});
});
});
@@ -13,6 +13,8 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useState, useEffect, useMemo } from 'react';
import debounce from 'lodash/debounce';
import { useTechDocsReaderPage } from './context';
/**
@@ -44,3 +46,50 @@ export const useShadowRootElements = <
.map(nodeList => Array.from(nodeList))
.flat();
};
const isValidSelection = (newSelection: Selection) => {
// Safari sets the selection rect to top zero
return (
newSelection.toString() &&
newSelection.rangeCount &&
newSelection.getRangeAt(0).getBoundingClientRect().top
);
};
/**
* Hook for retreiving a selection within the ShadowRoot.
* @public
*/
export const useShadowRootSelection = (wait: number = 0) => {
const shadowRoot = useShadowRoot();
const [selection, setSelection] = useState<Selection | null>(null);
const handleSelectionChange = useMemo(
() =>
debounce(() => {
const shadowDocument = shadowRoot as ShadowRoot &
Pick<Document, 'getSelection'>;
// Firefox and Safari don't implement getSelection for Shadow DOM
const newSelection = shadowDocument.getSelection
? shadowDocument.getSelection()
: document.getSelection();
if (newSelection && isValidSelection(newSelection)) {
setSelection(newSelection);
} else {
setSelection(null);
}
}, wait),
[shadowRoot, setSelection, wait],
);
useEffect(() => {
window.document.addEventListener('selectionchange', handleSelectionChange);
return () =>
window.document.removeEventListener(
'selectionchange',
handleSelectionChange,
);
}, [handleSelectionChange]);
return selection;
};