mirror of
https://github.com/suitenumerique/docs.git
synced 2026-09-12 04:37:53 +02:00
✨(frontend) Add "Copy link to block" feature
We added a new feature that allows users to copy a link to a specific block within the document. We can copy the link to the block by clicking on the "Copy link to block" button in the block's menu in the document editor. When the link is pasted in the browser, it will automatically scroll to the block.
This commit is contained in:
@@ -6,6 +6,10 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(frontend) Add "Copy link to block" feature #2547
|
||||
|
||||
### Changed
|
||||
|
||||
- ♿️(frontend) use anchor links for interlinking sub-documents #2391
|
||||
|
||||
@@ -121,6 +121,9 @@ test.describe('Doc Editor', () => {
|
||||
page.getByRole('menuitem', { name: 'Header column' }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: 'Copy link to block' }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('markdown button converts from markdown to the editor syntax json', async ({
|
||||
@@ -408,7 +411,7 @@ test.describe('Doc Editor', () => {
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
await page.locator('.bn-side-menu > button').last().click();
|
||||
await page.locator('.mantine-Menu-dropdown > button').last().click();
|
||||
await page.getByRole('menuitem', { name: 'Color' }).click();
|
||||
await page.locator('.bn-color-picker-dropdown > button').last().click();
|
||||
|
||||
await expect(
|
||||
@@ -654,4 +657,45 @@ test.describe('Doc Editor', () => {
|
||||
|
||||
await expect(editor.getByText('Mobile Text')).toBeVisible();
|
||||
});
|
||||
|
||||
test('it checks "Copy link to block" feature', async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
await createDoc(page, 'doc-scroll', browserName, 1);
|
||||
|
||||
const editor = await writeInEditor({ page, text: 'First Block' });
|
||||
|
||||
for (let i = 0; i < 30; i++) {
|
||||
await page.keyboard.press('Enter');
|
||||
}
|
||||
|
||||
await writeInEditor({ page, text: 'My Block' });
|
||||
|
||||
await editor
|
||||
.locator('.bn-block-outer')
|
||||
.filter({ hasText: 'My Block' })
|
||||
.first()
|
||||
.hover();
|
||||
|
||||
await page.locator('.bn-side-menu > button').last().click();
|
||||
await page.getByRole('menuitem', { name: 'Link to block' }).click();
|
||||
await expect(page.getByText('Link Copied !')).toBeVisible();
|
||||
|
||||
const url = page.url();
|
||||
|
||||
const handle = await page.evaluateHandle(() =>
|
||||
navigator.clipboard.readText(),
|
||||
);
|
||||
const clipboardContent = await handle.jsonValue();
|
||||
|
||||
await expect(editor.getByText('First Block')).not.toBeInViewport();
|
||||
await page.goto(url);
|
||||
await expect(editor.getByText('First Block')).toBeInViewport();
|
||||
await expect(editor.getByText('My Block')).not.toBeInViewport();
|
||||
|
||||
await page.goto(clipboardContent);
|
||||
await expect(editor.getByText('First Block')).not.toBeInViewport();
|
||||
await expect(editor.getByText('My Block')).toBeInViewport();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,6 +41,7 @@ import { AI_FEATURE_FLAG, DEFAULT_LOCALE } from '../conf';
|
||||
import {
|
||||
useHeadings,
|
||||
useSaveDoc,
|
||||
useScrollToBlockAnchor,
|
||||
useShortcuts,
|
||||
useUploadFile,
|
||||
useUploadStatus,
|
||||
@@ -267,6 +268,8 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
|
||||
|
||||
useUploadStatus(editor);
|
||||
|
||||
useScrollToBlockAnchor();
|
||||
|
||||
useEffect(() => {
|
||||
setEditor(editor);
|
||||
|
||||
|
||||
+2
@@ -21,6 +21,7 @@ import TableHeaderColumnIcon from '@/icons/table-header-column.svg';
|
||||
import TableHeaderRowIcon from '@/icons/table-header-row.svg';
|
||||
import TrashIcon from '@/icons/trash.svg';
|
||||
|
||||
import { LinkToBlockItem } from './LinkToBlockItem';
|
||||
import { TableHeaderSeparator } from './TableHeaderSeparator';
|
||||
|
||||
const DocsDragHandleMenu = () => {
|
||||
@@ -40,6 +41,7 @@ const DocsDragHandleMenu = () => {
|
||||
</Box>
|
||||
</BlockColorsItem>
|
||||
<HorizontalSeparator $margin={{ vertical: '3xs' }} />
|
||||
<LinkToBlockItem />
|
||||
<PresentBlockItem />
|
||||
<TableHeaderSeparator />
|
||||
<TableRowHeaderItem>
|
||||
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
import { SideMenuExtension } from '@blocknote/core/extensions';
|
||||
import {
|
||||
useBlockNoteEditor,
|
||||
useComponentsContext,
|
||||
useExtensionState,
|
||||
} from '@blocknote/react';
|
||||
import { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Box } from '@/components';
|
||||
import { useClipboard } from '@/hooks';
|
||||
import LinkIcon from '@/icons/link.svg';
|
||||
import { useResponsiveStore } from '@/stores/useResponsiveStore';
|
||||
|
||||
import type { DocsBlockNoteEditor } from '../../types';
|
||||
|
||||
export const LinkToBlockItem = () => {
|
||||
const { t } = useTranslation();
|
||||
const { isMobile } = useResponsiveStore();
|
||||
const Components = useComponentsContext();
|
||||
const editor: DocsBlockNoteEditor = useBlockNoteEditor();
|
||||
const copyToClipboard = useClipboard();
|
||||
const block = useExtensionState(SideMenuExtension, {
|
||||
editor,
|
||||
selector: (state) => state?.block,
|
||||
});
|
||||
|
||||
const copyLinkToBlock = useCallback(() => {
|
||||
if (!block?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
copyToClipboard(
|
||||
`${window.location.origin}${window.location.pathname}#${block.id}`,
|
||||
t('Link Copied !'),
|
||||
t('Failed to copy link'),
|
||||
);
|
||||
|
||||
if (!isMobile) {
|
||||
editor.focus();
|
||||
}
|
||||
|
||||
editor.setTextCursorPosition(block.id, 'end');
|
||||
}, [block?.id, copyToClipboard, editor, isMobile, t]);
|
||||
|
||||
if (Components === undefined || block === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Components.Generic.Menu.Item
|
||||
className="bn-menu-item"
|
||||
onClick={copyLinkToBlock}
|
||||
>
|
||||
<Box $align="center" $gap="xxs" $direction="row">
|
||||
<LinkIcon width="16" height="16" aria-hidden="true" />
|
||||
{t('Copy link to block')}
|
||||
</Box>
|
||||
</Components.Generic.Menu.Item>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './useHeadings';
|
||||
export * from './useSaveDoc';
|
||||
export * from './useScrollToBlockAnchor';
|
||||
export * from './useShortcuts';
|
||||
export * from './useUploadFile';
|
||||
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { getMainContentElement } from '@/layouts/utils';
|
||||
|
||||
const SCROLL_MARGIN_TOP = 50;
|
||||
const OBSERVER_TIMEOUT = 5000;
|
||||
|
||||
/**
|
||||
* Hook that scrolls to a block element based on the URL hash.
|
||||
* If the block element doesn't exist yet, it observes the DOM for a limited time
|
||||
* to see if it appears, and scrolls to it when it does.
|
||||
* If it doesn't appear within that time, it stops observing to avoid memory leaks.
|
||||
*/
|
||||
export const useScrollToBlockAnchor = () => {
|
||||
useEffect(() => {
|
||||
const blockId = window.location.hash.slice(1);
|
||||
|
||||
if (!blockId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existingBlockEl = document.getElementById(blockId);
|
||||
if (existingBlockEl) {
|
||||
scrollBlockIntoView(existingBlockEl);
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Document editor can be a bit slow to render the block elements.
|
||||
* If the block element doesn't exist yet, we observe the DOM
|
||||
* during the next OBSERVER_TIMEOUT milliseconds to see if it appears,
|
||||
* and scroll to it when it does.
|
||||
* If it doesn't appear within that time, we stop observing to avoid memory leaks.
|
||||
*/
|
||||
const observer = new MutationObserver(() => {
|
||||
const blockEl = document.getElementById(blockId);
|
||||
|
||||
if (blockEl) {
|
||||
clearTimeout(timeoutId);
|
||||
observer.disconnect();
|
||||
scrollBlockIntoView(blockEl);
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
|
||||
// Disconnect the observer after a timeout to avoid memory leaks if the block never appears
|
||||
const timeoutId = setTimeout(() => {
|
||||
observer.disconnect();
|
||||
}, OBSERVER_TIMEOUT);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
observer.disconnect();
|
||||
};
|
||||
}, []);
|
||||
};
|
||||
|
||||
// Try to scroll the main content container instead of the block itself
|
||||
// to avoid the block being hidden behind the header
|
||||
export const scrollBlockIntoView = (blockEl: HTMLElement) => {
|
||||
const container = getMainContentElement();
|
||||
|
||||
if (container) {
|
||||
const top =
|
||||
blockEl.getBoundingClientRect().top -
|
||||
container.getBoundingClientRect().top +
|
||||
container.scrollTop -
|
||||
SCROLL_MARGIN_TOP;
|
||||
|
||||
container.scrollTo({ top, behavior: 'smooth' });
|
||||
} else {
|
||||
blockEl.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
inline: 'start',
|
||||
block: 'start',
|
||||
});
|
||||
}
|
||||
};
|
||||
+3
-21
@@ -3,12 +3,10 @@ import { css } from 'styled-components';
|
||||
|
||||
import { Box, Text } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { scrollBlockIntoView } from '@/docs/doc-editor/hook/useScrollToBlockAnchor';
|
||||
import { DocsBlockNoteEditor } from '@/docs/doc-editor/types';
|
||||
import { getMainContentElement } from '@/layouts/utils';
|
||||
import { useResponsiveStore } from '@/stores';
|
||||
|
||||
const SCROLL_MARGIN_TOP = 50;
|
||||
|
||||
const leftPaddingMap: { [key: number]: string } = {
|
||||
3: '1.5rem',
|
||||
2: '0.9rem',
|
||||
@@ -61,24 +59,8 @@ export const Heading = ({
|
||||
|
||||
const blockEl = document.getElementById(headingId);
|
||||
|
||||
// Try to scroll the main content container instead of the block itself
|
||||
// to avoid the block being hidden behind the header
|
||||
const container = getMainContentElement();
|
||||
|
||||
if (blockEl && container) {
|
||||
const top =
|
||||
blockEl.getBoundingClientRect().top -
|
||||
container.getBoundingClientRect().top +
|
||||
container.scrollTop -
|
||||
SCROLL_MARGIN_TOP;
|
||||
|
||||
container.scrollTo({ top, behavior: 'smooth' });
|
||||
} else {
|
||||
blockEl?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
inline: 'start',
|
||||
block: 'start',
|
||||
});
|
||||
if (blockEl) {
|
||||
scrollBlockIntoView(blockEl);
|
||||
}
|
||||
}}
|
||||
$radius="var(--c--globals--spacings--st)"
|
||||
|
||||
Reference in New Issue
Block a user