diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2c544d2dd..1e075b1af 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts
index 3ad995ec2..57071ab92 100644
--- a/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts
+++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts
@@ -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();
+ });
});
diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx
index 446e8cd36..7548e3cf1 100644
--- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx
+++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx
@@ -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);
diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/DocsSideMenu/DocsSideMenu.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/DocsSideMenu/DocsSideMenu.tsx
index 7d330616a..529a6ede2 100644
--- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/DocsSideMenu/DocsSideMenu.tsx
+++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/DocsSideMenu/DocsSideMenu.tsx
@@ -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 = () => {
+
diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/DocsSideMenu/LinkToBlockItem.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/DocsSideMenu/LinkToBlockItem.tsx
new file mode 100644
index 000000000..cc5defd27
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/DocsSideMenu/LinkToBlockItem.tsx
@@ -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 (
+
+
+
+ {t('Copy link to block')}
+
+
+ );
+};
diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/hook/index.ts b/src/frontend/apps/impress/src/features/docs/doc-editor/hook/index.ts
index 95a0804b2..d647518a5 100644
--- a/src/frontend/apps/impress/src/features/docs/doc-editor/hook/index.ts
+++ b/src/frontend/apps/impress/src/features/docs/doc-editor/hook/index.ts
@@ -1,4 +1,5 @@
export * from './useHeadings';
export * from './useSaveDoc';
+export * from './useScrollToBlockAnchor';
export * from './useShortcuts';
export * from './useUploadFile';
diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useScrollToBlockAnchor.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useScrollToBlockAnchor.tsx
new file mode 100644
index 000000000..77e7bba58
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useScrollToBlockAnchor.tsx
@@ -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',
+ });
+ }
+};
diff --git a/src/frontend/apps/impress/src/features/docs/doc-table-content/components/Heading.tsx b/src/frontend/apps/impress/src/features/docs/doc-table-content/components/Heading.tsx
index e55febc42..2a9a58db5 100644
--- a/src/frontend/apps/impress/src/features/docs/doc-table-content/components/Heading.tsx
+++ b/src/frontend/apps/impress/src/features/docs/doc-table-content/components/Heading.tsx
@@ -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)"