From 2c5577d026a7a4be3a5f445129e22f070c32e8c3 Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Thu, 30 Jul 2026 13:32:46 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(frontend)=20Add=20"Copy=20link=20to?= =?UTF-8?q?=20block"=20feature?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 4 + .../__tests__/app-impress/doc-editor.spec.ts | 46 ++++++++++- .../doc-editor/components/BlockNoteEditor.tsx | 3 + .../components/DocsSideMenu/DocsSideMenu.tsx | 2 + .../DocsSideMenu/LinkToBlockItem.tsx | 61 ++++++++++++++ .../features/docs/doc-editor/hook/index.ts | 1 + .../hook/useScrollToBlockAnchor.tsx | 79 +++++++++++++++++++ .../doc-table-content/components/Heading.tsx | 24 +----- 8 files changed, 198 insertions(+), 22 deletions(-) create mode 100644 src/frontend/apps/impress/src/features/docs/doc-editor/components/DocsSideMenu/LinkToBlockItem.tsx create mode 100644 src/frontend/apps/impress/src/features/docs/doc-editor/hook/useScrollToBlockAnchor.tsx 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 ( + + + + + ); +}; 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)"