diff --git a/demo/tests/visual-tests/EditorPopupSelection.helpers.tsx b/demo/tests/visual-tests/EditorPopupSelection.helpers.tsx new file mode 100644 index 000000000..1414e2d8e --- /dev/null +++ b/demo/tests/visual-tests/EditorPopupSelection.helpers.tsx @@ -0,0 +1,175 @@ +import {useLayoutEffect, useRef, useState} from 'react'; + +import {MarkdownEditorView, useMarkdownEditor} from '@gravity-ui/markdown-editor'; +import {NodeSelection, Plugin, TextSelection} from '@gravity-ui/markdown-editor/pm/state'; +import type {EditorView} from '@gravity-ui/markdown-editor/pm/view'; +import {MobileProvider, ThemeProvider, Toaster, ToasterProvider} from '@gravity-ui/uikit'; +import * as ReactDOM from 'react-dom'; + +type Selection = {type: string; from: number; to: number}; +type Probe = { + view?: EditorView; + depth: number; + maxDepth: number; + selections: Selection[]; + destroyed: boolean; + updatesAfterDestroy: number; + settled: boolean; + publish: () => void; +}; + +const markup = '{% note info "Note" %}\n\nText in note\n\n{% endnote %}'; +const toaster = new Toaster(); + +function SelectionEditor(props: {probe: Probe; startsWithNote: boolean}) { + const {probe, startsWithNote} = props; + const editor = useMarkdownEditor({ + initial: { + markup: startsWithNote ? markup : `Before\n\n${markup}`, + mode: 'wysiwyg', + toolbarVisible: false, + }, + wysiwygConfig: { + extensions: (builder) => { + builder.addPlugin( + () => + new Plugin({ + view(editorView) { + const view = editorView; + probe.view = view; + const updateState = view.updateState; + // Instrument this instance only, including the complete plugin update cycle. + view.updateState = function (state) { + probe.depth += 1; + probe.maxDepth = Math.max(probe.maxDepth, probe.depth); + if (probe.destroyed) probe.updatesAfterDestroy += 1; + probe.selections.push({ + type: + state.selection instanceof NodeSelection + ? 'node' + : 'text', + from: state.selection.from, + to: state.selection.to, + }); + try { + return updateState.call(this, state); + } finally { + probe.depth -= 1; + probe.publish(); + } + }; + return { + destroy() { + probe.destroyed = true; + probe.publish(); + }, + }; + }, + }), + builder.Priority.Highest, + ); + }, + }, + }); + + return ; +} + +function SelectionApp({startsWithNote}: {startsWithNote: boolean}) { + const output = useRef(null); + const [mounted, setMounted] = useState(true); + const probe = useRef({ + depth: 0, + maxDepth: 0, + selections: [], + destroyed: false, + updatesAfterDestroy: 0, + settled: false, + publish() { + if (output.current) { + const {view: _view, publish: _publish, ...data} = probe; + output.current.textContent = JSON.stringify(data); + } + }, + }).current; + + function selectThen(action: 'move' | 'destroy') { + const view = probe.view; + if (!view) throw new Error('Editor view is missing'); + let notePos: number | undefined; + view.state.doc.descendants((node, pos) => { + if (node.type.name === 'yfm_note') notePos = pos; + }); + if (notePos === undefined) throw new Error('Note is missing'); + view.focus(); + probe.selections = []; + probe.maxDepth = 0; + view.dispatch(view.state.tr.setSelection(NodeSelection.create(view.state.doc, notePos))); + // Both actions happen before the deferred selection normalization can run. + if (action === 'move') { + view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, 1))); + } else { + ReactDOM.flushSync(() => setMounted(false)); + } + queueMicrotask(() => { + probe.settled = true; + probe.publish(); + }); + } + + return ( + + + + + + {mounted && ( + + )} + event.preventDefault()} + onClick={() => { + probe.selections = []; + probe.maxDepth = 0; + probe.publish(); + }} + > + Reset probe + + event.preventDefault()} + onClick={() => selectThen('move')} + > + Select note then paragraph + + event.preventDefault()} + onClick={() => selectThen('destroy')} + > + Select note then destroy + + + + + + ); +} + +export function EditorPopupSelection({ + legacy, + startsWithNote = false, +}: { + legacy: boolean; + startsWithNote?: boolean; +}) { + const target = useRef(null); + useLayoutEffect(() => { + if (!legacy || !target.current) return undefined; + const container = target.current; + ReactDOM.render(, container); + return () => { + ReactDOM.unmountComponentAtNode(container); + }; + }, [legacy, startsWithNote]); + return legacy ? : ; +} diff --git a/demo/tests/visual-tests/EditorPopupSelection.visual.test.tsx b/demo/tests/visual-tests/EditorPopupSelection.visual.test.tsx new file mode 100644 index 000000000..b4e973a0b --- /dev/null +++ b/demo/tests/visual-tests/EditorPopupSelection.visual.test.tsx @@ -0,0 +1,98 @@ +import {expect, test} from 'playwright/core'; + +import {EditorPopupSelection} from './EditorPopupSelection.helpers'; + +for (const legacy of [false, true]) { + test.describe(`Editor popup selection (${legacy ? 'legacy root' : 'createRoot'})`, () => { + for (const startsWithNote of [false, true]) { + test(`preserves selection behavior without nested updates (${startsWithNote ? 'menu already open' : 'from paragraph'})`, async ({ + mount, + page, + }) => { + const errors: string[] = []; + page.on('pageerror', (error) => errors.push(error.message)); + await mount( + , + ); + const editor = page.locator('.ProseMirror'); + const note = editor.locator('.yfm-note'); + const menu = page.getByTestId('g-md-toolbar-yfm-note'); + if (startsWithNote) { + await note.getByText('Text in note', {exact: true}).click(); + await expect(menu).toBeVisible(); + } else { + await editor.getByText('Before', {exact: true}).click(); + await expect(menu).toBeHidden(); + } + await page.getByRole('button', {name: 'Reset probe'}).dispatchEvent('click'); + await note.click({position: {x: 10, y: 10}}); + await expect(menu).toBeVisible(); + await expect + .poll(async () => { + const data = JSON.parse( + await page.getByTestId('selection-probe').innerText(), + ); + const node = data.selections.find( + (selection: {type: string}) => selection.type === 'node', + ); + const last = data.selections.at(-1); + return Boolean( + node && + (startsWithNote + ? last?.type === 'node' && + last.from === node.from && + last.to === node.to + : last?.type === 'text' && + last.from > node.from && + last.to < node.to && + last.from < last.to), + ); + }) + .toBe(true); + const data = JSON.parse(await page.getByTestId('selection-probe').innerText()); + expect(data.maxDepth).toBe(1); + if (startsWithNote) { + // Keeping the same open popup must not normalize a later block selection. + expect(data.selections.at(-1).type).toBe('node'); + await expect(note).toHaveClass(/ProseMirror-selectednode/); + } + await expect(editor).toBeFocused(); + expect(await page.evaluate(() => document.getSelection()?.toString())).toContain( + 'Text in note', + ); + expect(errors).toEqual([]); + }); + } + + for (const action of ['paragraph', 'destroy'] as const) { + test(`discards deferred work after ${action === 'paragraph' ? 'selection changes' : 'destroy'}`, async ({ + mount, + page, + }) => { + const errors: string[] = []; + page.on('pageerror', (error) => errors.push(error.message)); + await mount(); + await page.locator('.ProseMirror').getByText('Before', {exact: true}).click(); + await page.getByRole('button', {name: 'Reset probe'}).dispatchEvent('click'); + await page.getByRole('button', {name: `Select note then ${action}`}).click(); + const output = page.getByTestId('selection-probe'); + await expect + .poll(async () => JSON.parse(await output.innerText()).settled) + .toBe(true); + const data = JSON.parse(await output.innerText()); + expect(data.selections[0].type).toBe('node'); + expect(data.maxDepth).toBe(1); + expect(data.updatesAfterDestroy).toBe(0); + if (action === 'paragraph') { + expect(data.selections).toHaveLength(2); + expect(data.selections[1]).toEqual({type: 'text', from: 1, to: 1}); + await expect(page.getByTestId('g-md-toolbar-yfm-note')).toBeHidden(); + } else { + expect(data.destroyed).toBe(true); + await expect(page.locator('.ProseMirror')).toHaveCount(0); + } + expect(errors).toEqual([]); + }); + } + }); +} diff --git a/demo/tests/visual-tests/EditorTooltips.helpers.tsx b/demo/tests/visual-tests/EditorTooltips.helpers.tsx new file mode 100644 index 000000000..afacb181b --- /dev/null +++ b/demo/tests/visual-tests/EditorTooltips.helpers.tsx @@ -0,0 +1,20 @@ +import {MarkdownEditorView, useMarkdownEditor} from '@gravity-ui/markdown-editor'; +import {Button} from '@gravity-ui/uikit'; + +const defaultMarkup = + '{% note info "Note" %}\n\n#|\n|| Description |\n\n```js\nSelect this text inside the code block\n```\n\n||\n|| | Another table cell ||\n|#\n\n{% endnote %}'; + +export function EditorTooltips({markup = defaultMarkup}: {markup?: string}) { + const editor = useMarkdownEditor({ + initial: {markup, mode: 'wysiwyg', toolbarVisible: false}, + }); + + return ( + + + + + After editor + + ); +} diff --git a/demo/tests/visual-tests/EditorTooltips.visual.test.tsx b/demo/tests/visual-tests/EditorTooltips.visual.test.tsx new file mode 100644 index 000000000..933cbee28 --- /dev/null +++ b/demo/tests/visual-tests/EditorTooltips.visual.test.tsx @@ -0,0 +1,299 @@ +import {expect, test} from 'playwright/core'; + +import {EditorTooltips} from './EditorTooltips.helpers'; + +test.describe('Editor tooltips', () => { + test.beforeEach(async ({mount, page}) => { + await mount(); + // Language definitions load asynchronously; wait before opening the toolbar. + await expect(page.locator('pre [class*="hljs-"]').first()).toBeVisible(); + }); + + test('keep nested menus anchored during text selection', async ({page}) => { + const note = page.locator('.ProseMirror .yfm-note'); + const code = note.locator('pre'); + await expect(code).toBeVisible(); + await page.evaluate(() => document.fonts.ready); + + const box = await code.locator('code > div').evaluate((element) => { + const range = document.createRange(); + range.selectNodeContents(element); + const {x, y, height} = range.getBoundingClientRect(); + return {x, y, height}; + }); + + for (let attempt = 0; attempt < 2; attempt += 1) { + const capture = await note.evaluateHandle((element) => { + const originalCode = element.querySelector('pre'); + const unexpectedFallbacks: string[] = []; + const observer = new MutationObserver((records) => { + for (const record of records) { + for (const node of Array.from(record.addedNodes)) { + if ( + node instanceof HTMLElement && + node.matches('span[tabindex="-1"][aria-hidden="true"]') + ) { + unexpectedFallbacks.push(node.outerHTML); + } + } + } + }); + // Observe before the first pointer interaction, including mutations between frames. + const editor = element.closest('.ProseMirror'); + if (!editor) throw new Error('Editor root not found'); + observer.observe(editor, { + childList: true, + subtree: true, + }); + const snapshots: {connected: boolean | undefined; positions: number[]}[] = []; + let frameId: number; + const sample = () => { + snapshots.push({ + connected: element.isConnected && originalCode?.isConnected, + positions: Array.from(document.querySelectorAll('.g-md-base-tooltip')) + .filter((tooltip) => + tooltip.checkVisibility({ + opacityProperty: true, + visibilityProperty: true, + }), + ) + .map((tooltip) => tooltip.getBoundingClientRect().x), + }); + frameId = requestAnimationFrame(sample); + }; + sample(); + return { + stop: () => { + cancelAnimationFrame(frameId); + observer.disconnect(); + return {snapshots, unexpectedFallbacks}; + }, + }; + }); + + await page.mouse.move(box.x + 5, box.y + box.height / 2); + await page.mouse.down(); + await page.mouse.move(box.x + 180, box.y + box.height / 2, {steps: 30}); + await page.mouse.up(); + // The observation window includes the entire gesture and popup transitions. + await expect(page.locator('.g-md-base-tooltip')).toHaveCount(2); + await page + .locator('.g-md-code-block-toolbar') + .getByRole('combobox') + .click({trial: true}); + await page + .getByTestId('g-md-toolbar-yfm-note') + .getByRole('button') + .first() + .click({trial: true}); + await page.evaluate(() => new Promise(requestAnimationFrame)); + + const {snapshots, unexpectedFallbacks} = await capture.evaluate(({stop}) => stop()); + await capture.dispose(); + expect(unexpectedFallbacks).toEqual([]); + expect(snapshots.every(({connected}) => connected)).toBe(true); + expect(snapshots.some(({positions}) => positions.length === 2)).toBe(true); + expect(snapshots.every(({positions}) => positions.every((x) => x > 0))).toBe(true); + expect(await page.evaluate(() => document.getSelection()?.toString())).not.toBe(''); + } + + await expect + .poll(() => + page.evaluate(() => + [ + ['.g-md-code-block-toolbar', '.ProseMirror pre'], + ['.g-md-yfm-note-toolbar', '.ProseMirror .yfm-note'], + ].map(([menuSelector, anchorSelector]) => { + const menu = document + .querySelector(menuSelector) + ?.closest('[data-floating-ui-placement]'); + const anchor = document.querySelector(anchorSelector); + if (!menu || !anchor) return false; + const rect = menu.getBoundingClientRect(); + const anchorRect = anchor.getBoundingClientRect(); + const gap = menu + .getAttribute('data-floating-ui-placement') + ?.startsWith('top') + ? anchorRect.top - rect.bottom + : rect.top - anchorRect.bottom; + const centerX = rect.x + rect.width / 2; + const hit = document.elementFromPoint(centerX, rect.y + rect.height / 2); + return ( + Math.abs(centerX - (anchorRect.x + anchorRect.width / 2)) < 1 && + Math.abs(gap - 4) < 1 && + menu.contains(hit) + ); + }), + ), + ) + .toEqual([true, true]); + + const outside = page.getByRole('button', {name: 'After editor'}); + await outside.click(); + await expect(outside).toBeFocused(); + await expect(page.locator('.g-md-base-tooltip')).toHaveCount(0); + await expect(code).toHaveText('Select this text inside the code block'); + }); + + test('returns focus after Escape in the language selector', async ({page}) => { + await page.locator('.ProseMirror pre').click(); + const select = page.getByRole('combobox'); + await select.click(); + await expect(page.getByRole('listbox')).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(page.getByRole('listbox')).toBeHidden(); + await expect(select).toBeFocused(); + await page.keyboard.press('Tab'); + await expect(page.getByRole('button', {name: 'Line numbers'})).toBeFocused(); + await page.keyboard.press('Shift+Tab'); + await expect(select).toBeFocused(); + await page.keyboard.press('Escape'); + await expect(page.locator('.g-md-code-block-toolbar')).toBeHidden(); + await expect(page.locator('.ProseMirror')).toBeFocused(); + }); + + for (const block of [ + {name: 'code', selector: 'pre', action: 'code-block-remove'}, + {name: 'note', selector: '.yfm-note', action: 'note-remove'}, + ]) { + test(`keeps editing after removing the ${block.name} block`, async ({page}) => { + const editor = page.locator('.ProseMirror'); + const node = editor.locator(block.selector); + await editor.locator('pre').click(); + await page.locator(`[data-toolbar-item="${block.action}"]`).click(); + await expect(node).toHaveCount(0); + await expect(editor).toBeFocused(); + await page.keyboard.type('Continue editing'); + await expect(editor).toContainText('Continue editing'); + }); + } +}); + +test('Editor tooltips follow a different block and scrolling', async ({mount, page}) => { + await mount(, { + rootStyle: {minHeight: 1200}, + }); + await expect(page.locator('pre [class*="hljs-"]').first()).toBeVisible(); + const blocks = page.locator('.ProseMirror pre'); + const menu = page.locator('.g-md-code-block-toolbar'); + + for (const index of [0, 1, 0]) { + await blocks.nth(index).click(); + await expect(menu).toBeVisible(); + const scrollY = await page.evaluate(() => window.scrollY); + await page.mouse.wheel(0, 20); + await expect.poll(() => page.evaluate(() => window.scrollY)).toBe(scrollY + 20); + await expect + .poll(async () => { + const anchor = await blocks.nth(index).boundingBox(); + const popup = await menu + .locator('xpath=ancestor::*[@data-floating-ui-placement]') + .boundingBox(); + if (!anchor || !popup) return Infinity; + return Math.abs(popup.y - (anchor.y + anchor.height) - 4); + }) + .toBeLessThan(2); + } +}); + +test('Editor tooltips keep markdown table menus outside the document', async ({mount, page}) => { + await mount(, { + rootStyle: {minHeight: 1200}, + }); + const editor = page.locator('.ProseMirror'); + const capture = await editor.evaluateHandle((root) => { + const table = root.querySelector('table'); + const cells = Array.from(root.querySelectorAll('td, th')); + const unexpectedFallbacks: string[] = []; + const observer = new MutationObserver((records) => { + for (const record of records) { + for (const node of Array.from(record.addedNodes)) { + if ( + node instanceof HTMLElement && + node.matches('span[tabindex="-1"][aria-hidden="true"]') + ) { + unexpectedFallbacks.push(node.outerHTML); + } + } + } + }); + observer.observe(root, {subtree: true, childList: true}); + return { + connected: () => Boolean(table?.isConnected) && cells.every((cell) => cell.isConnected), + stop: () => { + observer.disconnect(); + return unexpectedFallbacks; + }, + }; + }); + const switcher = page.locator('.table-cell-floating-button'); + for (const text of ['Body', 'Cell', 'Body']) { + await editor.getByText(text, {exact: true}).click(); + await expect(switcher).toBeVisible(); + const scrollY = await page.evaluate(() => window.scrollY); + await page.mouse.wheel(0, 20); + await expect.poll(() => page.evaluate(() => window.scrollY)).toBe(scrollY + 20); + await expect + .poll(async () => { + const cell = await editor.locator('td').filter({hasText: text}).boundingBox(); + const popup = await switcher + .locator('xpath=ancestor::*[@data-floating-ui-placement]') + .boundingBox(); + if (!cell || !popup) return false; + return ( + Math.abs(popup.x + popup.width - cell.x - 12) < 1 && + Math.abs(popup.y - cell.y - 2) < 1 + ); + }) + .toBe(true); + await switcher.click(); + await expect(page.getByRole('menu')).toBeVisible(); + await page.getByText('Align cell content to the right', {exact: true}).click({trial: true}); + await page.getByRole('button', {name: 'After editor'}).click(); + await expect(page.getByRole('menu')).toBeHidden(); + } + + expect(await capture.evaluate(({connected}) => connected())).toBe(true); + await page.getByRole('button', {name: 'After editor'}).click(); + await expect(page.getByRole('button', {name: 'After editor'})).toBeFocused(); + const result = await capture.evaluate(({stop}) => stop()); + await capture.dispose(); + expect(result).toEqual([]); +}); + +test('Editor tooltips keep markdown table keyboard navigation', async ({mount, page}) => { + // Keep the page stationary: DropdownMenu intentionally dismisses on document scroll. + await mount(); + await page.locator('.ProseMirror').getByText('Body', {exact: true}).click(); + await page.locator('.table-cell-floating-button').click(); + const first = page.getByRole('menuitem').first(); + await first.click({trial: true}); + await first.focus(); + await expect(first).toBeFocused(); + await page.keyboard.press('Tab'); + await expect(page.getByRole('menuitem').nth(1)).toBeFocused(); + await page.keyboard.press('Shift+Tab'); + await expect(first).toBeFocused(); + await page.keyboard.press('Escape'); + await expect(page.getByRole('menu')).toBeHidden(); +}); + +test('Editor tooltips keep markdown table commands working', async ({mount, page}) => { + await mount(); + const editor = page.locator('.ProseMirror'); + const switcher = page.locator('.table-cell-floating-button'); + await editor.getByText('Body', {exact: true}).click(); + await switcher.click(); + await page.getByText('Align cell content to the right', {exact: true}).click(); + await expect(editor.locator('td').filter({hasText: 'Body'})).toHaveAttribute( + 'cell-align', + 'right', + ); + await editor.getByText('Body', {exact: true}).click(); + await switcher.click(); + await page.getByText('Remove table', {exact: true}).click(); + await expect(editor.locator('table')).toHaveCount(0); + await editor.click(); + await page.keyboard.type('Continue editing'); + await expect(editor).toContainText('Continue editing'); +}); diff --git a/demo/tests/visual-tests/playground/CodeBlock.visual.test.tsx b/demo/tests/visual-tests/playground/CodeBlock.visual.test.tsx index b89cf6d73..7305935d3 100644 --- a/demo/tests/visual-tests/playground/CodeBlock.visual.test.tsx +++ b/demo/tests/visual-tests/playground/CodeBlock.visual.test.tsx @@ -33,6 +33,7 @@ test.describe('CodeBlock', () => { await wait.visible(editor.locators.contenteditable.locator('code')); await editor.codeBlock.waitForToolbarVisible(); + // The markup preview updates asynchronously and changes the screenshot height. await expect(page.locator('.playground__markup')).toHaveText('```\n\n```'); await expectScreenshot(); }); diff --git a/demo/tests/visual-tests/playground/Note.visual.test.tsx b/demo/tests/visual-tests/playground/Note.visual.test.tsx index ffc185278..97e9d2958 100644 --- a/demo/tests/visual-tests/playground/Note.visual.test.tsx +++ b/demo/tests/visual-tests/playground/Note.visual.test.tsx @@ -107,6 +107,7 @@ test.describe('Note', () => { page, wait, }) => { + const selection = page.locator('.playground__pm-selection'); const markup = dd` ## YFM Note @@ -142,10 +143,14 @@ test.describe('Note', () => { }, }); + await expect(selection).toContainText('TextSelection'); + const nestedSelection = await selection.innerText(); await editor.yfmNote.clickYfmNoteToolbarButton('Alert'); await page.mouse.move(-1, -1); await wait.timeout(500); + await expect(editor.locators.contenteditable).toBeFocused(); + await expect(selection).toHaveText(nestedSelection, {useInnerText: true}); await expectScreenshot({nameSuffix: 'nested-note-is-alert'}); const parentNote = page.getByText('Parent note title').first().locator('..'); @@ -159,10 +164,14 @@ test.describe('Note', () => { }); await wait.timeout(); + await expect(selection).toContainText('TextSelection'); + const parentSelection = await selection.innerText(); await editor.yfmNote.clickYfmNoteToolbarButton('Note'); await page.mouse.move(-1, -1); await wait.timeout(500); + await expect(editor.locators.contenteditable).toBeFocused(); + await expect(selection).toHaveText(parentSelection, {useInnerText: true}); await expectScreenshot({nameSuffix: 'parent-note-is-info'}); }); }); diff --git a/packages/editor/src/extensions/markdown/Table/plugins/TableCellContextPlugin/floating.tsx b/packages/editor/src/extensions/markdown/Table/plugins/TableCellContextPlugin/floating.tsx index 393dfc799..3bca83dfe 100644 --- a/packages/editor/src/extensions/markdown/Table/plugins/TableCellContextPlugin/floating.tsx +++ b/packages/editor/src/extensions/markdown/Table/plugins/TableCellContextPlugin/floating.tsx @@ -1,7 +1,8 @@ import {Ellipsis} from '@gravity-ui/icons'; -import {DropdownMenu, type DropdownMenuItemMixed, Icon, Popup} from '@gravity-ui/uikit'; +import {DropdownMenu, type DropdownMenuItemMixed, Icon} from '@gravity-ui/uikit'; import type {Action} from '../../../../../core'; +import {EditorPopup} from '../../../../../plugins/BaseTooltip/EditorPopup'; import './floating.scss'; @@ -12,20 +13,23 @@ export type TableCellFloatingButtonMixed = export type TableCellFloatingButtonActions = TableCellFloatingButtonMixed[]; export type TableCellFloatingButtonProps = { + editorElement: HTMLElement; dom?: Element; actions: TableCellFloatingButtonActions; }; -export const TableCellFloatingButton: React.FC = ({dom, actions}) => { +export const TableCellFloatingButton: React.FC = ({ + editorElement, + dom, + actions, +}) => { if (!dom) { return null; } return ( - = ( defaultSwitcherClassName="table-cell-floating-button" items={actions.map(buildMenuItem)} /> - + ); }; TableCellFloatingButton.displayName = 'TableCellFloatingButton'; diff --git a/packages/editor/src/extensions/markdown/Table/plugins/TableCellContextPlugin/view.tsx b/packages/editor/src/extensions/markdown/Table/plugins/TableCellContextPlugin/view.tsx index 4c53901dc..b064b2528 100644 --- a/packages/editor/src/extensions/markdown/Table/plugins/TableCellContextPlugin/view.tsx +++ b/packages/editor/src/extensions/markdown/Table/plugins/TableCellContextPlugin/view.tsx @@ -36,6 +36,7 @@ export class TableCellContextView implements PluginView { () => ( diff --git a/packages/editor/src/plugins/BaseTooltip/EditorPopup.tsx b/packages/editor/src/plugins/BaseTooltip/EditorPopup.tsx new file mode 100644 index 000000000..3a1ce582e --- /dev/null +++ b/packages/editor/src/plugins/BaseTooltip/EditorPopup.tsx @@ -0,0 +1,30 @@ +import {useLayoutEffect} from 'react'; + +import {useFloatingRootContext} from '@floating-ui/react'; +import {Popup, type PopupProps} from '@gravity-ui/uikit'; + +type EditorPopupProps = Pick & { + editorElement: HTMLElement; + anchorElement: Element | null; +}; + +export function EditorPopup({ + editorElement, + anchorElement, + onOpenChange, + ...props +}: EditorPopupProps) { + const context = useFloatingRootContext({ + open: true, + onOpenChange, + elements: {reference: editorElement, floating: null}, + }); + + useLayoutEffect(() => { + // Keep focus on the editor root: Floating UI inserts its focus fallback next to + // the DOM reference, and ProseMirror owns all DOM inside the editor. + context.refs.setPositionReference(anchorElement); + }, [anchorElement, context.refs]); + + return ; +} diff --git a/packages/editor/src/plugins/BaseTooltip/index.tsx b/packages/editor/src/plugins/BaseTooltip/index.tsx index 722269832..e5a8877ef 100644 --- a/packages/editor/src/plugins/BaseTooltip/index.tsx +++ b/packages/editor/src/plugins/BaseTooltip/index.tsx @@ -1,6 +1,6 @@ -import {Popup, type PopupPlacement, type PopupProps} from '@gravity-ui/uikit'; +import type {PopupPlacement, PopupProps} from '@gravity-ui/uikit'; import type {Mark, MarkType, Node, NodeType} from 'prosemirror-model'; -import {NodeSelection, type PluginView} from 'prosemirror-state'; +import {NodeSelection, type PluginView, TextSelection} from 'prosemirror-state'; // @ts-ignore // TODO: fix cjs build import {findDomRefAtPos, findParentNodeOfType, findSelectedNodeOfType} from 'prosemirror-utils'; import type {EditorView} from 'prosemirror-view'; @@ -12,6 +12,8 @@ import { } from '../../extensions/behavior/ReactRenderer'; import {ErrorLoggerBoundary} from '../../react-utils/ErrorBoundary'; +import {EditorPopup} from './EditorPopup'; + import './index.scss'; export type BaseTooltipNode = { @@ -74,6 +76,8 @@ export class BaseTooltipPluginView implements PluginView { private readonly idPrefix: string; private renderItem?: RendererItem; + private selectionUpdate: object | null = null; + private popupAnchor: HTMLElement | null = null; constructor(view: EditorView, options: BaseTooltipPluginOptions) { this.view = view; @@ -92,6 +96,8 @@ export class BaseTooltipPluginView implements PluginView { destroy() { this.destroyed = true; + this.selectionUpdate = null; + this.stopTrackingViewFocus(); this.renderItem?.remove(); this.renderItem = undefined; } @@ -187,6 +193,11 @@ export class BaseTooltipPluginView implements PluginView { protected render() { if (this.destroyed) return; + const anchor = this.content && !this.manualHidden ? (this.currentNode?.dom ?? null) : null; + if (anchor !== this.popupAnchor) { + this.popupAnchor = anchor; + this.scheduleTextSelection(); + } this.renderItem = this.renderItem ?? this.createRenderItem(); this.renderItem.rerender(); } @@ -195,9 +206,8 @@ export class BaseTooltipPluginView implements PluginView { this.hidePopupManual(); }; - protected popupOpenChangeHandler: PopupProps['onOpenChange'] = (open, event, reason) => { + protected popupOpenChangeHandler: PopupProps['onOpenChange'] = (open, _event, reason) => { if (open) return; - if (reason === 'focus-out' && (event as FocusEvent).relatedTarget === this.view.dom) return; if (this.disableHideOnEscapeKeyDown && reason === 'escape-key') return; this.hidePopupManual(); @@ -229,6 +239,7 @@ export class BaseTooltipPluginView implements PluginView { } protected hidePopupManual() { + this.selectionUpdate = null; this.manualHidden = true; this.render(); } @@ -236,9 +247,8 @@ export class BaseTooltipPluginView implements PluginView { protected renderContent(currentNode: BaseTooltipNode): React.ReactNode { if (!this.content) return null; return ( - - + ); } @@ -265,6 +275,54 @@ export class BaseTooltipPluginView implements PluginView { ); } + private scheduleTextSelection() { + this.selectionUpdate = null; + const view = this.view; + const {doc, selection} = view.state; + const anchor = this.currentNode?.dom; + if ( + this.destroyed || + this.manualHidden || + !this.content || + !anchor || + !view.hasFocus() || + !(selection instanceof NodeSelection) || + selection.node.isAtom || + view.nodeDOM(selection.from) !== anchor + ) + return; + + const update = {}; + this.selectionUpdate = update; + // Wait until all plugin views finish updating, including synchronous legacy React roots. + queueMicrotask(() => { + if (this.selectionUpdate !== update) return; + this.selectionUpdate = null; + if ( + this.destroyed || + view.isDestroyed || + this.view !== view || + this.manualHidden || + !view.hasFocus() || + view.state.doc !== doc || + !view.state.selection.eq(selection) || + this.currentNode?.dom !== anchor || + view.nodeDOM(selection.from) !== anchor + ) + return; + + // Preserve the existing text selection when a block toolbar opens, without DOM mutations. + view.dispatch( + view.state.tr.setSelection( + TextSelection.between( + doc.resolve(selection.from + 1), + doc.resolve(selection.to - 1), + ), + ), + ); + }); + } + private rerenderCb = () => { this.render(); };