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)"