diff --git a/CHANGELOG.md b/CHANGELOG.md index ac490d64ab..4e2c6cdb9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to - ♿️(frontend) restore skip to content link after header redesign #2510 - 🌐(i18n) rename cn_CN to zh_CN, add eo_PL and zh_TW locales #2486 +- ✨(frontend) Add "Copy link to block" feature #2547 ### Fixed 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 0524167227..57071ab92c 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 @@ -23,12 +23,8 @@ test.describe('Doc Editor', () => { }) => { await createDoc(page, 'doc-toolbar', browserName, 1); - await verifyDocName(page, 'doc-toolbar'); - const editor = await writeInEditor({ page, text: 'test content' }); - await page.waitForTimeout(1500); - await editor .getByText('test content', { exact: true, @@ -106,6 +102,30 @@ test.describe('Doc Editor', () => { ).toBeVisible(); }); + test('it checks side menu buttons are displayed', async ({ + page, + browserName, + }) => { + await createDoc(page, 'doc-side-menu', browserName, 1); + + const { editor } = await openSuggestionMenu({ page, suggestion: 'Table' }); + + await editor.locator('.tableWrapper').first().hover(); + + await page.locator('.bn-side-menu > button').last().click(); + await expect(page.getByRole('menuitem', { name: 'Colors' })).toBeVisible(); + await expect( + page.getByRole('menuitem', { name: 'Header row' }), + ).toBeVisible(); + await expect( + 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 ({ page, browserName, @@ -391,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( @@ -637,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/assets/icons/ui-kit/color.svg b/src/frontend/apps/impress/src/assets/icons/ui-kit/color.svg new file mode 100644 index 0000000000..e629d51d20 --- /dev/null +++ b/src/frontend/apps/impress/src/assets/icons/ui-kit/color.svg @@ -0,0 +1,15 @@ + + + + + + + + + + diff --git a/src/frontend/apps/impress/src/assets/icons/ui-kit/link.svg b/src/frontend/apps/impress/src/assets/icons/ui-kit/link.svg index 787f1e16a9..d9dda38187 100644 --- a/src/frontend/apps/impress/src/assets/icons/ui-kit/link.svg +++ b/src/frontend/apps/impress/src/assets/icons/ui-kit/link.svg @@ -1,3 +1,17 @@ - - + + + + + + + + + + diff --git a/src/frontend/apps/impress/src/assets/icons/ui-kit/table-header-column.svg b/src/frontend/apps/impress/src/assets/icons/ui-kit/table-header-column.svg new file mode 100644 index 0000000000..e801a932f5 --- /dev/null +++ b/src/frontend/apps/impress/src/assets/icons/ui-kit/table-header-column.svg @@ -0,0 +1,31 @@ + + + + + + + diff --git a/src/frontend/apps/impress/src/assets/icons/ui-kit/table-header-row.svg b/src/frontend/apps/impress/src/assets/icons/ui-kit/table-header-row.svg new file mode 100644 index 0000000000..a4ce9e912a --- /dev/null +++ b/src/frontend/apps/impress/src/assets/icons/ui-kit/table-header-row.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/frontend/apps/impress/src/assets/icons/ui-kit/trash.svg b/src/frontend/apps/impress/src/assets/icons/ui-kit/trash.svg index b63de38cb9..4aa8523672 100644 --- a/src/frontend/apps/impress/src/assets/icons/ui-kit/trash.svg +++ b/src/frontend/apps/impress/src/assets/icons/ui-kit/trash.svg @@ -1,3 +1,6 @@ - - + + 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 5c9b2b1e29..ce5728c61b 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, @@ -53,6 +54,7 @@ import { randomColor, sanitizeColor } from '../utils'; import BlockNoteAI from './AI'; import { BlockNoteSuggestionMenu } from './BlockNoteSuggestionMenu'; import { BlockNoteToolbar } from './BlockNoteToolBar/BlockNoteToolbar'; +import { DocsSideMenu } from './DocsSideMenu/DocsSideMenu'; import { CalloutBlock, PdfBlock, UploadLoaderBlock } from './custom-blocks'; const AIMenu = BlockNoteAI?.AIMenu; const AIMenuController = BlockNoteAI?.AIMenuController; @@ -263,6 +265,8 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { useUploadStatus(editor); + useScrollToBlockAnchor(); + useEffect(() => { setEditor(editor); @@ -292,6 +296,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { editor={editor} formattingToolbar={false} slashMenu={false} + sideMenu={false} theme="light" comments={false} aria-label={t('Document editor')} @@ -303,6 +308,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { )} + {showComments && } {showComments && !isCommentSideBarOpen && } {threadsSidebarTarget && 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 new file mode 100644 index 0000000000..ad8e526779 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/DocsSideMenu/DocsSideMenu.tsx @@ -0,0 +1,71 @@ +/** + * Overrides the default DragHandleMenu with a custom implementation for the Docs editor. + * See: https://github.com/TypeCellOS/BlockNote/blob/main/packages/react/src/components/SideMenu/DragHandleMenu/DragHandleMenu.tsx + */ +import { + BlockColorsItem, + RemoveBlockItem, + SideMenu, + SideMenuController, + TableColumnHeaderItem, + TableRowHeaderItem, + useComponentsContext, + useDictionary, +} from '@blocknote/react'; + +import { Box, HorizontalSeparator } from '@/components'; +import ColorIcon from '@/icons/color.svg'; +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 = () => { + const Components = useComponentsContext(); + const dict = useDictionary(); + + if (Components === undefined) { + return null; + } + + return ( + + + + + {dict.drag_handle.colors_menuitem} + + + + + + + + + {dict.drag_handle.header_row_menuitem} + + + + + + {dict.drag_handle.header_column_menuitem} + + + + + + + {dict.drag_handle.delete_menuitem} + + + + ); +}; + +export const DocsSideMenu = () => ( + } + /> +); 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 0000000000..8886b90dda --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/DocsSideMenu/LinkToBlockItem.tsx @@ -0,0 +1,53 @@ +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 type { DocsBlockNoteEditor } from '../../types'; + +export const LinkToBlockItem = () => { + const { t } = useTranslation(); + const Components = useComponentsContext(); + const editor: DocsBlockNoteEditor = useBlockNoteEditor(); + const copyToClipboard = useClipboard(); + const block = useExtensionState(SideMenuExtension, { + editor, + selector: (state) => state?.block, + }); + + const copyLinkToBlock = useCallback(() => { + if (!block) { + return; + } + + copyToClipboard( + `${window.location.origin}${window.location.pathname}#${block.id}`, + t('Link Copied !'), + t('Failed to copy link'), + ); + }, [block, copyToClipboard, 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/components/DocsSideMenu/TableHeaderSeparator.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/DocsSideMenu/TableHeaderSeparator.tsx new file mode 100644 index 0000000000..bae9030f22 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/DocsSideMenu/TableHeaderSeparator.tsx @@ -0,0 +1,24 @@ +import { SideMenuExtension } from '@blocknote/core/extensions'; +import { useBlockNoteEditor, useExtensionState } from '@blocknote/react'; + +import { HorizontalSeparator } from '@/components'; + +import type { DocsBlockNoteEditor } from '../../types'; + +export const TableHeaderSeparator = () => { + const editor: DocsBlockNoteEditor = useBlockNoteEditor(); + const block = useExtensionState(SideMenuExtension, { + editor, + selector: (state) => state?.block, + }); + + if ( + block === undefined || + block.type !== 'table' || + !editor.settings.tables.headers + ) { + 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 95a0804b22..d647518a5e 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 0000000000..77e7bba582 --- /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 e55febc427..2a9a58db5b 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)"