diff --git a/cypress/e2e/editor/basic/panel_selection.cy.ts b/cypress/e2e/editor/basic/panel_selection.cy.ts new file mode 100644 index 000000000..0074f88da --- /dev/null +++ b/cypress/e2e/editor/basic/panel_selection.cy.ts @@ -0,0 +1,158 @@ +import { AuthTestUtils } from '../../../support/auth-utils'; +import { EditorSelectors, SlashCommandSelectors, waitForReactUpdate } from '../../../support/selectors'; +import { generateRandomEmail } from '../../../support/test-config'; + +describe('Panel Selection - Shift+Arrow Keys', () => { + const authUtils = new AuthTestUtils(); + const testEmail = generateRandomEmail(); + + before(() => { + cy.viewport(1280, 720); + }); + + beforeEach(() => { + cy.on('uncaught:exception', () => false); + + cy.session(testEmail, () => { + authUtils.signInWithTestUrl(testEmail); + }, { + validate: () => { + cy.window().then((win) => { + const token = win.localStorage.getItem('af_auth_token'); + expect(token).to.be.ok; + }); + } + }); + + cy.visit('/app'); + cy.url({ timeout: 30000 }).should('include', '/app'); + cy.contains('Getting started', { timeout: 10000 }).should('be.visible').click(); + cy.wait(2000); + + EditorSelectors.firstEditor().click({ force: true }); + cy.focused().type('{selectall}{backspace}'); + waitForReactUpdate(500); + }); + + describe('Slash Panel Selection', () => { + it('should allow Shift+Arrow selection when slash panel is open', () => { + // Type some text first + cy.focused().type('Hello World'); + waitForReactUpdate(200); + + // Open slash panel + cy.focused().type('/'); + waitForReactUpdate(500); + + // Verify slash panel is open + SlashCommandSelectors.slashPanel().should('be.visible'); + + // Type search text + cy.focused().type('head'); + waitForReactUpdate(200); + + // Now try Shift+Left to select text - this should work after the fix + cy.focused().type('{shift}{leftArrow}{leftArrow}{leftArrow}{leftArrow}'); + waitForReactUpdate(200); + + // The selection should have happened - verify by typing replacement text + // Close panel first + cy.focused().type('{esc}'); + waitForReactUpdate(200); + + // The text "head" should still be visible (since we selected but didn't delete) + EditorSelectors.slateEditor().should('contain.text', 'head'); + }); + + it('should allow Shift+Right selection when slash panel is open', () => { + // Type some text first + cy.focused().type('Test Content'); + waitForReactUpdate(200); + + // Move cursor to after "Test " + cy.focused().type('{home}'); + cy.focused().type('{rightArrow}{rightArrow}{rightArrow}{rightArrow}{rightArrow}'); + waitForReactUpdate(200); + + // Open slash panel + cy.focused().type('/'); + waitForReactUpdate(500); + + // Verify slash panel is open + SlashCommandSelectors.slashPanel().should('be.visible'); + + // Type search text + cy.focused().type('para'); + waitForReactUpdate(200); + + // Try Shift+Right to extend selection + cy.focused().type('{shift}{rightArrow}{rightArrow}'); + waitForReactUpdate(200); + + // Close panel + cy.focused().type('{esc}'); + waitForReactUpdate(200); + + // Verify editor still has content + EditorSelectors.slateEditor().should('contain.text', 'Test'); + }); + + it('should still block plain Arrow keys when panel is open', () => { + // Type some text + cy.focused().type('Sample Text'); + waitForReactUpdate(200); + + // Open slash panel + cy.focused().type('/'); + waitForReactUpdate(500); + + // Verify slash panel is open + SlashCommandSelectors.slashPanel().should('be.visible'); + + // Type search text + cy.focused().type('heading'); + waitForReactUpdate(200); + + // Press plain ArrowLeft (without Shift) - should be blocked + cy.focused().type('{leftArrow}'); + waitForReactUpdate(200); + + // Panel should still be open (cursor didn't move away from trigger position) + SlashCommandSelectors.slashPanel().should('be.visible'); + + // Close panel + cy.focused().type('{esc}'); + waitForReactUpdate(200); + + // Verify content + EditorSelectors.slateEditor().should('contain.text', 'Sample Text'); + }); + }); + + describe('Mention Panel Selection', () => { + it('should allow Shift+Arrow selection when mention panel is open', () => { + // Type some text first + cy.focused().type('Hello '); + waitForReactUpdate(200); + + // Open mention panel with @ + cy.focused().type('@'); + waitForReactUpdate(500); + + // Type to search + cy.focused().type('test'); + waitForReactUpdate(200); + + // Try Shift+Left to select - should work after fix + cy.focused().type('{shift}{leftArrow}{leftArrow}'); + waitForReactUpdate(200); + + // Close panel + cy.focused().type('{esc}'); + waitForReactUpdate(200); + + // Editor should still have content + EditorSelectors.slateEditor().should('contain.text', 'Hello'); + }); + }); +}); diff --git a/cypress/e2e/editor/basic/text_editing.cy.ts b/cypress/e2e/editor/basic/text_editing.cy.ts index 8f774d5c0..480ff6b9a 100644 --- a/cypress/e2e/editor/basic/text_editing.cy.ts +++ b/cypress/e2e/editor/basic/text_editing.cy.ts @@ -1,5 +1,5 @@ import { AuthTestUtils } from '../../../support/auth-utils'; -import { EditorSelectors, waitForReactUpdate } from '../../../support/selectors'; +import { EditorSelectors, waitForReactUpdate, byTestId } from '../../../support/selectors'; import { generateRandomEmail, getCmdKey, getWordJumpKey } from '../../../support/test-config'; describe('Basic Text Editing', () => { @@ -54,7 +54,7 @@ describe('Basic Text Editing', () => { // If trigger doesn't work (often Slate relies on beforeInput), try one more fallback: // type('{del}') again but assume it might need a retry or check. // Actually, let's trust type('{del}') but double check focus. - cy.get('[data-slate-editor="true"]').focus().type('{del}'); + EditorSelectors.slateEditor().focus().type('{del}'); waitForReactUpdate(500); // "Test |ext" @@ -153,8 +153,8 @@ describe('Basic Text Editing', () => { // Use robust selection via data-testid if available, or fallback to text with wait cy.get('body').then($body => { - if ($body.find('[data-testid="slash-menu-heading1"]').length > 0) { - cy.get('[data-testid="slash-menu-heading1"]').click(); + if ($body.find(byTestId('slash-menu-heading1')).length > 0) { + cy.get(byTestId('slash-menu-heading1')).click(); } else if ($body.text().includes('Heading 1')) { cy.contains('Heading 1').first().click(); } else { @@ -192,8 +192,8 @@ describe('Basic Text Editing', () => { waitForReactUpdate(1000); cy.get('body').then($body => { - if ($body.find('[data-testid="slash-menu-bulletedList"]').length > 0) { - cy.get('[data-testid="slash-menu-bulletedList"]').click(); + if ($body.find(byTestId('slash-menu-bulletedList')).length > 0) { + cy.get(byTestId('slash-menu-bulletedList')).click(); } else if ($body.text().includes('Bulleted list')) { cy.contains('Bulleted list').first().click(); } else { diff --git a/cypress/e2e/editor/collaboration/tab_sync.cy.ts b/cypress/e2e/editor/collaboration/tab_sync.cy.ts index 28f7d3827..6f0cfbf9e 100644 --- a/cypress/e2e/editor/collaboration/tab_sync.cy.ts +++ b/cypress/e2e/editor/collaboration/tab_sync.cy.ts @@ -73,7 +73,7 @@ describe('Editor Tab Synchronization', () => { // 1. Type in Main Window cy.log('Typing in Main Window'); // Click topLeft to avoid iframe overlay at bottom right - cy.get('[data-slate-editor="true"]').first().click('topLeft', { force: true }).type('Hello from Main'); + EditorSelectors.slateEditor().first().click('topLeft', { force: true }).type('Hello from Main'); waitForReactUpdate(2000); // Wait longer for sync // 2. Verify in Iframe with longer timeout @@ -86,6 +86,6 @@ describe('Editor Tab Synchronization', () => { waitForReactUpdate(2000); // 4. Verify in Main Window with longer timeout - cy.get('[data-slate-editor="true"]', { timeout: 15000 }).should('contain.text', 'Hello from Main and Iframe'); + EditorSelectors.slateEditor({ timeout: 15000 }).should('contain.text', 'Hello from Main and Iframe'); }); }); diff --git a/cypress/e2e/editor/commands/editor_commands.cy.ts b/cypress/e2e/editor/commands/editor_commands.cy.ts index 27015280a..8e0fa2190 100644 --- a/cypress/e2e/editor/commands/editor_commands.cy.ts +++ b/cypress/e2e/editor/commands/editor_commands.cy.ts @@ -1,5 +1,5 @@ import { AuthTestUtils } from '../../../support/auth-utils'; -import { EditorSelectors, waitForReactUpdate } from '../../../support/selectors'; +import { BlockSelectors, EditorSelectors, waitForReactUpdate } from '../../../support/selectors'; import { generateRandomEmail } from '../../../support/test-config'; describe('Editor Commands', () => { @@ -79,7 +79,7 @@ describe('Editor Commands', () => { cy.focused().type('{shift}{enter}'); waitForReactUpdate(200); cy.focused().type('Line 2'); - cy.get('[data-block-type="paragraph"]').should('have.length', 1); + BlockSelectors.blockByType('paragraph').should('have.length', 1); cy.contains('Line 1').should('be.visible'); cy.contains('Line 2').should('be.visible'); }); diff --git a/cypress/e2e/editor/cursor/editor_interaction.cy.ts b/cypress/e2e/editor/cursor/editor_interaction.cy.ts index 7e935500b..bf65f4e9b 100644 --- a/cypress/e2e/editor/cursor/editor_interaction.cy.ts +++ b/cypress/e2e/editor/cursor/editor_interaction.cy.ts @@ -1,5 +1,5 @@ import { AuthTestUtils } from '../../../support/auth-utils'; -import { EditorSelectors, waitForReactUpdate } from '../../../support/selectors'; +import { BlockSelectors, EditorSelectors, waitForReactUpdate } from '../../../support/selectors'; import { generateRandomEmail, getCmdKey } from '../../../support/test-config'; describe('Editor Navigation & Interaction', () => { @@ -43,11 +43,11 @@ describe('Editor Navigation & Interaction', () => { waitForReactUpdate(200); cy.focused().type('X'); waitForReactUpdate(200); - cy.get('[data-slate-editor="true"]').should('contain.text', 'XStart Middle End'); + EditorSelectors.slateEditor().should('contain.text', 'XStart Middle End'); cy.focused().type('{selectall}{rightArrow}'); waitForReactUpdate(200); cy.focused().type('Y'); - cy.get('[data-slate-editor="true"]').should('contain.text', 'XStart Middle EndY'); + EditorSelectors.slateEditor().should('contain.text', 'XStart Middle EndY'); }); it('should navigate character by character', () => { @@ -64,7 +64,7 @@ describe('Editor Navigation & Interaction', () => { cy.focused().type('-'); // Expect "W-ord" - cy.get('[data-slate-editor="true"]').should('contain.text', 'W-ord'); + EditorSelectors.slateEditor().should('contain.text', 'W-ord'); }); it('should select word on double click', () => { @@ -80,8 +80,8 @@ describe('Editor Navigation & Interaction', () => { cy.focused().type('Replaced'); // 'SelectMe' should be gone, 'Replaced' should be present - cy.get('[data-slate-editor="true"]').should('contain.text', 'Replaced'); - cy.get('[data-slate-editor="true"]').should('not.contain.text', 'SelectMe'); + EditorSelectors.slateEditor().should('contain.text', 'Replaced'); + EditorSelectors.slateEditor().should('not.contain.text', 'SelectMe'); }); it('should navigate up/down between blocks', () => { @@ -131,8 +131,8 @@ describe('Editor Navigation & Interaction', () => { // Type to verify focus cy.focused().type(' UpTest'); // Verify 'UpTest' appears in Paragraph block and NOT in List Block - cy.get('[data-block-type="paragraph"]').should('contain.text', 'UpTest'); - cy.get('[data-block-type="bulleted_list"]').should('not.contain.text', 'UpTest'); + BlockSelectors.blockByType('paragraph').should('contain.text', 'UpTest'); + BlockSelectors.blockByType('bulleted_list').should('not.contain.text', 'UpTest'); // Test Navigation: Heading -> Paragraph // Click Heading first to change focus @@ -144,8 +144,8 @@ describe('Editor Navigation & Interaction', () => { cy.focused().type(' DownTest'); // Verify 'DownTest' appears in Paragraph block and NOT in Heading Block - cy.get('[data-block-type="paragraph"]').should('contain.text', 'DownTest'); - cy.get('[data-block-type="heading"]').should('not.contain.text', 'DownTest'); + BlockSelectors.blockByType('paragraph').should('contain.text', 'DownTest'); + BlockSelectors.blockByType('heading').should('not.contain.text', 'DownTest'); }); }); @@ -187,7 +187,7 @@ describe('Editor Navigation & Interaction', () => { describe('Style Interaction', () => { it.skip('should persist bold style when typing inside bold text', () => { cy.focused().type('Normal '); - cy.get('[data-slate-editor="true"]').click(); + EditorSelectors.slateEditor().click(); cy.focused().type(`${cmdKey}b`); waitForReactUpdate(200); cy.focused().type('Bold'); @@ -198,7 +198,7 @@ describe('Editor Navigation & Interaction', () => { }); it('should reset style when creating a new paragraph', () => { - cy.get('[data-slate-editor="true"]').click(); + EditorSelectors.slateEditor().click(); cy.focused().type(`${cmdKey}b`); waitForReactUpdate(200); cy.focused().type('Heading Bold'); diff --git a/cypress/e2e/editor/drag_drop_blocks.cy.ts b/cypress/e2e/editor/drag_drop_blocks.cy.ts new file mode 100644 index 000000000..460a299b7 --- /dev/null +++ b/cypress/e2e/editor/drag_drop_blocks.cy.ts @@ -0,0 +1,372 @@ +import { AuthTestUtils } from '../../support/auth-utils'; +import { BlockSelectors, EditorSelectors, waitForReactUpdate } from '../../support/selectors'; +import { generateRandomEmail } from '../../support/test-config'; + +describe('Editor - Drag and Drop Blocks', () => { + beforeEach(() => { + cy.on('uncaught:exception', (err) => { + if ( + err.message.includes('Minified React error') || + err.message.includes('View not found') || + err.message.includes('No workspace or service found') || + err.message.includes('Cannot resolve a DOM point from Slate point') + ) { + return false; + } + return true; + }); + + cy.viewport(1280, 720); + }); + + const dragBlock = (sourceText: string, targetText: string, edge: 'top' | 'bottom') => { + cy.log(`Dragging "${sourceText}" to ${edge} of "${targetText}"`); + + // 1. Hover over the source block to reveal controls + // Use a selector that works for text-containing blocks AND empty/special blocks if needed + // For text blocks, cy.contains works. For others, we might need a more specific selector if sourceText is a selector. + const getSource = () => { + // Heuristic: if sourceText looks like a selector (starts with [), use find inside editor, else contains inside editor + return sourceText.startsWith('[') + ? EditorSelectors.slateEditor().find(sourceText) + : EditorSelectors.slateEditor().contains(sourceText); + }; + + getSource().closest('[data-block-type]').scrollIntoView().should('be.visible').click().then(($sourceBlock) => { + // Use realHover to simulate user interaction which updates elementFromPoint + cy.wrap($sourceBlock).trigger('mouseover', { force: true }); + cy.wrap($sourceBlock).realHover({ position: 'center' }); + + // Force visibility of hover controls to avoid flakiness + BlockSelectors.hoverControls().invoke('css', 'opacity', '1'); + + // 2. Get the drag handle + BlockSelectors.dragHandle().should('be.visible').then(($handle) => { + const dataTransfer = new DataTransfer(); + + // 3. Start dragging + cy.wrap($handle).trigger('dragstart', { + dataTransfer, + force: true, + eventConstructor: 'DragEvent' + }); + cy.wait(100); + + // 4. Find target and drop + EditorSelectors.slateEditor().contains(targetText).closest('[data-block-type]').then(($targetBlock) => { + const rect = $targetBlock[0].getBoundingClientRect(); + + const clientX = rect.left + (rect.width / 2); + const clientY = edge === 'top' + ? rect.top + (rect.height * 0.25) + : rect.top + (rect.height * 0.75); + + // Simulate the dragover to trigger the drop indicator + cy.wrap($targetBlock).trigger('dragenter', { + dataTransfer, + clientX, + clientY, + force: true, + eventConstructor: 'DragEvent' + }); + + cy.wrap($targetBlock).trigger('dragover', { + dataTransfer, + clientX, + clientY, + force: true, + eventConstructor: 'DragEvent' + }); + + cy.wait(300); // Wait for drop indicator + + // Drop + cy.wrap($targetBlock).trigger('drop', { + dataTransfer, + clientX, + clientY, + force: true, + eventConstructor: 'DragEvent' + }); + + // End drag + cy.wrap($handle).trigger('dragend', { + dataTransfer, + force: true, + eventConstructor: 'DragEvent' + }); + }); + }); + }); + + waitForReactUpdate(1000); + }; + + it.skip('should iteratively reorder items in a list (5 times)', () => { + const testEmail = generateRandomEmail(); + const authUtils = new AuthTestUtils(); + + cy.visit('/login', { failOnStatusCode: false }); + authUtils.signInWithTestUrl(testEmail).then(() => { + cy.url({ timeout: 30000 }).should('include', '/app'); + cy.contains('Getting started').click(); + + cy.get('[data-slate-editor="true"]').click().type('{selectall}{backspace}'); + waitForReactUpdate(500); + + // Create List: 1, 2, 3, 4, 5 + cy.focused().type('1. Item 1{enter}'); + cy.focused().type('Item 2{enter}'); + cy.focused().type('Item 3{enter}'); + cy.focused().type('Item 4{enter}'); + cy.focused().type('Item 5{enter}'); + waitForReactUpdate(1000); + + // Iterate 5 times: Drag first item ("Item 1") to the bottom ("Item 5", then whatever is last) + // Actually, to be predictable: + // 1. Drag Item 1 to bottom of Item 5. Order: 2, 3, 4, 5, 1 + // 2. Drag Item 2 to bottom of Item 1. Order: 3, 4, 5, 1, 2 + // 3. Drag Item 3 to bottom of Item 2. Order: 4, 5, 1, 2, 3 + // 4. Drag Item 4 to bottom of Item 3. Order: 5, 1, 2, 3, 4 + // 5. Drag Item 5 to bottom of Item 4. Order: 1, 2, 3, 4, 5 (Back to start!) + + const items = ['Item 1', 'Item 2', 'Item 3', 'Item 4', 'Item 5']; + + for (let i = 0; i < 5; i++) { + const itemToMove = items[i]; + const targetItem = items[(i + 4) % 5]; // The current last item + + cy.log(`Iteration ${i + 1}: Moving ${itemToMove} below ${targetItem}`); + dragBlock(itemToMove, targetItem, 'bottom'); + // Add extra wait for stability + cy.wait(2000); + } + + // Verify final order (Should be 1, 2, 3, 4, 5) + items.forEach((item, index) => { + BlockSelectors.blockByType('numbered_list').eq(index).should('contain.text', item); + }); + + // Reload and verify + /* + cy.reload(); + cy.get('[data-slate-editor="true"]', { timeout: 30000 }).should('exist'); + waitForReactUpdate(2000); + + items.forEach((item, index) => { + BlockSelectors.blockByType('numbered_list').eq(index).should('contain.text', item); + }); + */ + }); + }); + + it('should reorder Header and Paragraph blocks', () => { + const testEmail = generateRandomEmail(); + const authUtils = new AuthTestUtils(); + + cy.visit('/login', { failOnStatusCode: false }); + authUtils.signInWithTestUrl(testEmail).then(() => { + cy.url({ timeout: 30000 }).should('include', '/app'); + cy.contains('Getting started').click(); + + cy.get('[data-slate-editor="true"]').click().type('{selectall}{backspace}'); + waitForReactUpdate(500); + + // Create Header + cy.focused().type('/'); + waitForReactUpdate(1000); + cy.contains('Heading 1').should('be.visible').click(); + waitForReactUpdate(500); + + cy.focused().type('Header Block'); + cy.focused().type('{enter}'); // New line + + // Create Paragraph + cy.focused().type('Paragraph Block'); + waitForReactUpdate(1000); + + // Verify initial order: Header, Paragraph + BlockSelectors.blockByType('heading').should('exist'); + BlockSelectors.blockByType('paragraph').should('exist'); + + // Drag Header below Paragraph + dragBlock('Header Block', 'Paragraph Block', 'bottom'); + + // Verify Order: Paragraph, Header + BlockSelectors.allBlocks().then($blocks => { + const textBlocks = $blocks.filter((i, el) => + el.textContent?.includes('Header Block') || el.textContent?.includes('Paragraph Block') + ); + expect(textBlocks[0]).to.contain.text('Paragraph Block'); + expect(textBlocks[1]).to.contain.text('Header Block'); + }); + + // Reload and verify + /* + cy.reload(); + cy.get('[data-slate-editor="true"]', { timeout: 30000 }).should('exist'); + waitForReactUpdate(2000); + + cy.get('[data-block-type]').then($blocks => { + const textBlocks = $blocks.filter((i, el) => + el.textContent?.includes('Header Block') || el.textContent?.includes('Paragraph Block') + ); + expect(textBlocks[0]).to.contain.text('Paragraph Block'); + expect(textBlocks[1]).to.contain.text('Header Block'); + }); + */ + }); + }); + + it('should reorder Callout block', () => { + const testEmail = generateRandomEmail(); + const authUtils = new AuthTestUtils(); + + cy.visit('/login', { failOnStatusCode: false }); + authUtils.signInWithTestUrl(testEmail).then(() => { + cy.url({ timeout: 30000 }).should('include', '/app'); + cy.contains('Getting started').click(); + + cy.get('[data-slate-editor="true"]').click().type('{selectall}{backspace}'); + waitForReactUpdate(500); + + // Create text blocks first + cy.focused().type('Top Text{enter}'); + cy.focused().type('Bottom Text'); + waitForReactUpdate(500); + + // Move cursor back to Top Text to insert callout after it + cy.contains('Top Text').click().type('{end}{enter}'); + + // Create Callout Block + cy.focused().type('/callout'); + waitForReactUpdate(1000); + cy.contains('Callout').should('be.visible').click(); + waitForReactUpdate(1000); + + cy.focused().type('Callout Content'); + waitForReactUpdate(500); + + // Verify callout block exists + BlockSelectors.blockByType('callout').should('exist'); + + // Initial State: Top Text, Callout, Bottom Text + // Action: Drag Callout below Bottom Text + // Note: dragBlock supports selectors, so we can pass the data-block-type selector string + // Ideally we'd use a helper if dragBlock supported it, but it expects string. + // We can construct the string using the same logic or just pass literal for now. + // Or update dragBlock to take an element? dragBlock logic: if sourceText starts with [, use cy.get. + dragBlock('[data-block-type="callout"]', 'Bottom Text', 'bottom'); + + // Verify: Top Text, Bottom Text, Callout + BlockSelectors.allBlocks().then($blocks => { + const relevant = $blocks.filter((i, el) => + el.textContent?.includes('Top Text') || + el.textContent?.includes('Bottom Text') || + el.textContent?.includes('Callout Content') + ); + expect(relevant[0]).to.contain.text('Top Text'); + expect(relevant[1]).to.contain.text('Bottom Text'); + expect(relevant[2]).to.contain.text('Callout Content'); + }); + }); + }); + + it('should drag and drop an image block', () => { + const testEmail = generateRandomEmail(); + const authUtils = new AuthTestUtils(); + + cy.visit('/login', { failOnStatusCode: false }); + authUtils.signInWithTestUrl(testEmail).then(() => { + cy.url({ timeout: 30000 }).should('include', '/app'); + cy.contains('Getting started').click(); + + cy.get('[data-slate-editor="true"]').click().type('{selectall}{backspace}'); + waitForReactUpdate(500); + + // Create text blocks + cy.focused().type('Top Text{enter}'); + cy.focused().type('Bottom Text{enter}'); + + // Create Image Block + cy.focused().type('/image'); + waitForReactUpdate(1000); + cy.contains('Image').should('be.visible').click(); + waitForReactUpdate(1000); + + // Close the image upload popover/modal if it appears + cy.get('body').type('{esc}'); + waitForReactUpdate(500); + + // Verify image block exists + BlockSelectors.blockByType('image').should('exist'); + + // Initial State: Top Text, Bottom Text, Image (at bottom) + // Drag Image between Top and Bottom + dragBlock('[data-block-type="image"]', 'Top Text', 'bottom'); + + // Verify: Top Text, Image, Bottom Text + BlockSelectors.allBlocks().then($blocks => { + const relevant = $blocks.filter((i, el) => + el.textContent?.includes('Top Text') || + el.textContent?.includes('Bottom Text') || + el.getAttribute('data-block-type') === 'image' + ); + expect(relevant[0]).to.contain.text('Top Text'); + expect(relevant[1]).to.have.attr('data-block-type', 'image'); + expect(relevant[2]).to.contain.text('Bottom Text'); + }); + }); + }); + + it('should drag and drop a grid block', () => { + const testEmail = generateRandomEmail(); + const authUtils = new AuthTestUtils(); + + cy.visit('/login', { failOnStatusCode: false }); + authUtils.signInWithTestUrl(testEmail).then(() => { + cy.url({ timeout: 30000 }).should('include', '/app'); + cy.contains('Getting started').click(); + + cy.get('[data-slate-editor="true"]').click().type('{selectall}{backspace}'); + waitForReactUpdate(500); + + // Create text blocks + cy.focused().type('Top Text{enter}'); + cy.focused().type('Bottom Text{enter}'); + + // Create Grid Block + cy.focused().type('/grid'); + waitForReactUpdate(1000); + // Note: "Grid" might be the label. Adjust if "Table" or "Database" is used in UI. + // Based on SlashPanel code, it is "Grid". + BlockSelectors.slashMenuGrid().should('be.visible').click(); + waitForReactUpdate(2000); + + // Grid creation usually opens a modal. We need to close it to interact with the editor. + // Pressing ESC is a robust way to close modals. + cy.get('body').type('{esc}'); + waitForReactUpdate(1000); + + // Verify grid block exists + BlockSelectors.blockByType('grid').should('exist'); + + // Initial State: Top Text, Bottom Text, Grid + // Drag Grid between Top and Bottom + dragBlock('[data-block-type="grid"]', 'Top Text', 'bottom'); + + // Verify: Top Text, Grid, Bottom Text + BlockSelectors.allBlocks().then($blocks => { + const relevant = $blocks.filter((i, el) => + el.textContent?.includes('Top Text') || + el.textContent?.includes('Bottom Text') || + el.getAttribute('data-block-type') === 'grid' + ); + expect(relevant[0]).to.contain.text('Top Text'); + expect(relevant[1]).to.have.attr('data-block-type', 'grid'); + expect(relevant[2]).to.contain.text('Bottom Text'); + }); + }); + }); + +}); \ No newline at end of file diff --git a/cypress/e2e/editor/formatting/slash-menu-formatting.cy.ts b/cypress/e2e/editor/formatting/slash-menu-formatting.cy.ts index 7e1397e76..ca9fe257f 100644 --- a/cypress/e2e/editor/formatting/slash-menu-formatting.cy.ts +++ b/cypress/e2e/editor/formatting/slash-menu-formatting.cy.ts @@ -1,5 +1,5 @@ import { AuthTestUtils } from '../../../support/auth-utils'; -import { waitForReactUpdate } from '../../../support/selectors'; +import { EditorSelectors, waitForReactUpdate } from '../../../support/selectors'; import { generateRandomEmail } from '../../../support/test-config'; describe('Slash Menu - Text Formatting', () => { @@ -35,7 +35,7 @@ describe('Slash Menu - Text Formatting', () => { cy.wait(5000); // Give page time to fully load // Focus on editor - cy.get('[data-slate-editor="true"]').should('exist').click(); + EditorSelectors.slateEditor().should('exist').click(); waitForReactUpdate(1000); // Type slash to open menu @@ -82,7 +82,7 @@ describe('Slash Menu - Text Formatting', () => { cy.wait(5000); // Focus on editor and move to end - cy.get('[data-slate-editor="true"]').should('exist').click(); + EditorSelectors.slateEditor().should('exist').click(); cy.focused().type('{end}'); cy.focused().type('{enter}{enter}'); // Add some space waitForReactUpdate(1000); @@ -100,7 +100,7 @@ describe('Slash Menu - Text Formatting', () => { waitForReactUpdate(500); // Verify the text was added - cy.get('[data-slate-editor="true"]').should('contain.text', 'Test Heading'); + EditorSelectors.slateEditor().should('contain.text', 'Test Heading'); cy.log('Heading 1 added successfully'); }); diff --git a/cypress/e2e/editor/lists/editor_lists.cy.ts b/cypress/e2e/editor/lists/editor_lists.cy.ts index 1dbd3b0e4..665174213 100644 --- a/cypress/e2e/editor/lists/editor_lists.cy.ts +++ b/cypress/e2e/editor/lists/editor_lists.cy.ts @@ -78,7 +78,7 @@ describe('Editor Lists Manipulation', () => { waitForReactUpdate(1000); cy.focused().type('Test bullet item'); waitForReactUpdate(500); - cy.get('[data-slate-editor="true"]').should('contain.text', 'Test bullet item'); + EditorSelectors.slateEditor().should('contain.text', 'Test bullet item'); }); }); }); diff --git a/cypress/e2e/editor/toolbar/editor_toolbar.cy.ts b/cypress/e2e/editor/toolbar/editor_toolbar.cy.ts index 39903564d..b472404d8 100644 --- a/cypress/e2e/editor/toolbar/editor_toolbar.cy.ts +++ b/cypress/e2e/editor/toolbar/editor_toolbar.cy.ts @@ -1,5 +1,5 @@ import { AuthTestUtils } from '../../../support/auth-utils'; -import { EditorSelectors, waitForReactUpdate } from '../../../support/selectors'; +import { BlockSelectors, EditorSelectors, waitForReactUpdate } from '../../../support/selectors'; import { generateRandomEmail } from '../../../support/test-config'; describe('Toolbar Interaction', () => { @@ -45,8 +45,8 @@ describe('Toolbar Interaction', () => { cy.focused().type('Link text'); showToolbar('Link text'); - cy.get('[data-testid="selection-toolbar"]').within(() => { - cy.get('[data-testid="link-button"]').click({ force: true }); + EditorSelectors.selectionToolbar().within(() => { + EditorSelectors.linkButton().click({ force: true }); }); waitForReactUpdate(200); @@ -58,8 +58,8 @@ describe('Toolbar Interaction', () => { cy.focused().type('Colored text'); showToolbar('Colored text'); - cy.get('[data-testid="selection-toolbar"]').within(() => { - cy.get('[data-testid="text-color-button"]').click({ force: true }); + EditorSelectors.selectionToolbar().within(() => { + EditorSelectors.textColorButton().click({ force: true }); }); waitForReactUpdate(200); @@ -71,8 +71,8 @@ describe('Toolbar Interaction', () => { cy.focused().type('Highlighted text'); showToolbar('Highlighted text'); - cy.get('[data-testid="selection-toolbar"]').within(() => { - cy.get('[data-testid="bg-color-button"]').click({ force: true }); + EditorSelectors.selectionToolbar().within(() => { + EditorSelectors.bgColorButton().click({ force: true }); }); waitForReactUpdate(200); @@ -84,13 +84,13 @@ describe('Toolbar Interaction', () => { cy.focused().type('Convert me'); showToolbar('Convert me'); - cy.get('[data-testid="selection-toolbar"]').within(() => { - cy.get('[data-testid="heading-button"]').click({ force: true }); + EditorSelectors.selectionToolbar().within(() => { + EditorSelectors.headingButton().click({ force: true }); }); waitForReactUpdate(200); cy.get('.MuiPopover-root').should('exist').should('be.visible'); - cy.get('[data-testid="heading-1-button"]').should('exist'); + EditorSelectors.heading1Button().should('exist'); }); // New Tests for Alignment @@ -105,7 +105,7 @@ describe('Toolbar Interaction', () => { cy.focused().type('List Item'); showToolbar('List Item'); - cy.get('[data-testid="selection-toolbar"]').within(() => { + EditorSelectors.selectionToolbar().within(() => { // Find BulletedList button. Assuming standard icon or tooltip // We need a way to identify it. Assuming order or tooltip. // Let's try to find by svg name if possible or tooltip. @@ -114,23 +114,22 @@ describe('Toolbar Interaction', () => { }); waitForReactUpdate(200); - cy.get('[data-slate-editor="true"]').should('contain.text', 'List Item'); + EditorSelectors.slateEditor().should('contain.text', 'List Item'); // Verify list structure (ul/li or AppFlowy specific block) // AppFlowy uses specific block types. - // We can check for the presence of the bullet marker in the DOM - cy.get('.bullet-list-marker, [data-block-type="bulleted_list"]').should('exist'); + BlockSelectors.blockByType('bulleted_list').should('exist'); }); it('should apply Numbered List via toolbar', () => { cy.focused().type('Numbered Item'); showToolbar('Numbered Item'); - cy.get('[data-testid="selection-toolbar"]').within(() => { + EditorSelectors.selectionToolbar().within(() => { cy.get('button[aria-label*="Numbered list"], button[title*="Numbered list"]').click({ force: true }); }); waitForReactUpdate(200); - cy.get('[data-block-type="numbered_list"]').should('exist'); + BlockSelectors.blockByType('numbered_list').should('exist'); }); // New Test for Quote via Toolbar @@ -138,12 +137,12 @@ describe('Toolbar Interaction', () => { cy.focused().type('Quote Text'); showToolbar('Quote Text'); - cy.get('[data-testid="selection-toolbar"]').within(() => { + EditorSelectors.selectionToolbar().within(() => { cy.get('button[aria-label*="Quote"], button[title*="Quote"]').click({ force: true }); }); waitForReactUpdate(200); - cy.get('[data-block-type="quote"]').should('exist'); + BlockSelectors.blockByType('quote').should('exist'); }); // New Test for Inline Code via Toolbar diff --git a/cypress/e2e/embeded/database/database-bottom-scroll-simple.cy.ts b/cypress/e2e/embeded/database/database-bottom-scroll-simple.cy.ts new file mode 100644 index 000000000..a9428e84e --- /dev/null +++ b/cypress/e2e/embeded/database/database-bottom-scroll-simple.cy.ts @@ -0,0 +1,129 @@ +import { AuthTestUtils } from '../../../support/auth-utils'; +import { getSlashMenuItemName } from '../../../support/i18n-constants'; +import { + EditorSelectors, + SlashCommandSelectors, + waitForReactUpdate +} from '../../../support/selectors'; +import { generateRandomEmail } from '../../../support/test-config'; + +describe('Embedded Database - Bottom Scroll Preservation (Simplified)', () => { + + beforeEach(() => { + cy.on('uncaught:exception', (err) => { + if (err.message.includes('Minified React error') || + err.message.includes('View not found') || + err.message.includes('No workspace or service found') || + err.message.includes('Cannot resolve a DOM point from Slate point') || + err.message.includes('No range and node found')) { + return false; + } + return true; + }); + + cy.viewport(1280, 720); + }); + + it('should preserve scroll position when creating grid at bottom', () => { + const testEmail = generateRandomEmail(); + + cy.task('log', `[TEST] Email: ${testEmail}`); + + // Login + const authUtils = new AuthTestUtils(); + + // Use cy.session for authentication like the working tests + cy.session(testEmail, () => { + authUtils.signInWithTestUrl(testEmail); + }, { + validate: () => { + cy.window().then((win) => { + const token = win.localStorage.getItem('af_auth_token'); + expect(token).to.be.ok; + }); + } + }); + + // Visit app and open Getting Started document (like working tests) + cy.visit('/app'); + cy.url({ timeout: 30000 }).should('include', '/app'); + cy.contains('Getting started', { timeout: 10000 }).should('be.visible').click(); + cy.wait(2000); + + // Clear existing content and add 30 lines + EditorSelectors.firstEditor().click({ force: true }); + cy.focused().type('{selectall}{backspace}'); + waitForReactUpdate(500); + + let content = ''; + for (let i = 1; i <= 30; i++) { + content += `Line ${i} content{enter}`; + } + cy.focused().type(content, { delay: 1 }); + waitForReactUpdate(2000); + + // Scroll to bottom + cy.get('.appflowy-scroll-container').first().then($container => { + const scrollHeight = $container[0].scrollHeight; + const clientHeight = $container[0].clientHeight; + const targetScroll = scrollHeight - clientHeight; + + // Scroll to bottom using DOM + $container[0].scrollTop = targetScroll; + cy.task('log', `[SCROLL] Scrolled to: ${targetScroll}`); + }); + + cy.wait(1000); // Give more time for any scroll settling + + // Record scroll position and store it globally so code can access it + let scrollBefore = 0; + cy.get('.appflowy-scroll-container').first().then($container => { + scrollBefore = $container[0].scrollTop; + cy.task('log', `[SCROLL] Immediately before typing "/": ${scrollBefore}`); + + // Store in window so our code can access it + cy.window().then((win) => { + win.__CYPRESS_EXPECTED_SCROLL__ = scrollBefore; + }); + }); + + // Create database at bottom (cursor already at end from previous typing, just type "/") + // Don't click - clicking might cause scroll. Use cy.focused() since cursor is already there. + cy.focused().type('/', { delay: 0 }); + waitForReactUpdate(500); + + SlashCommandSelectors.slashPanel().should('be.visible').within(() => { + SlashCommandSelectors.slashMenuItem(getSlashMenuItemName('grid')).first().click(); + }); + + waitForReactUpdate(2000); + + // Check modal opened + cy.get('[role="dialog"]', { timeout: 10000 }).should('be.visible'); + + // CRITICAL: Verify scroll position is preserved (didn't jump to top) + cy.get('.appflowy-scroll-container').first().then($container => { + const scrollAfter = $container[0].scrollTop; + const scrollDelta = Math.abs(scrollAfter - scrollBefore); + + cy.task('log', `[SCROLL] After grid creation: ${scrollAfter}`); + cy.task('log', `[SCROLL] Scroll delta: ${scrollBefore} -> ${scrollAfter} (changed by ${scrollAfter - scrollBefore})`); + + // Verify scroll didn't jump to top (the bug we're testing for) + if (scrollAfter < 200) { + cy.task('log', `[FAIL] Document scrolled to top (${scrollAfter})! This is the bug we are testing for.`); + } + + // Should NOT scroll to top (scrollAfter should be > 200) + expect(scrollAfter).to.be.greaterThan(200, + `Document should not scroll to top when creating database at bottom. Before: ${scrollBefore}, After: ${scrollAfter}`); + + // Verify scroll stayed close to original position (within 100px tolerance) + // This ensures we not only avoided scrolling to top, but preserved the actual position + expect(scrollDelta).to.be.lessThan(100, + `Scroll position should be preserved within 100px. Before: ${scrollBefore}, After: ${scrollAfter}, Delta: ${scrollDelta}`); + + cy.task('log', `[SUCCESS] Scroll preserved! Before: ${scrollBefore}, After: ${scrollAfter}, Delta: ${scrollDelta}px`); + }); + }); +}); diff --git a/cypress/e2e/embeded/database/database-bottom-scroll.cy.ts b/cypress/e2e/embeded/database/database-bottom-scroll.cy.ts new file mode 100644 index 000000000..932f6d8fd --- /dev/null +++ b/cypress/e2e/embeded/database/database-bottom-scroll.cy.ts @@ -0,0 +1,251 @@ +import { v4 as uuidv4 } from 'uuid'; +import { AuthTestUtils } from '../../../support/auth-utils'; +import { getSlashMenuItemName } from '../../../support/i18n-constants'; +import { + AddPageSelectors, + DatabaseGridSelectors, + EditorSelectors, + ModalSelectors, + SlashCommandSelectors, + waitForReactUpdate +} from '../../../support/selectors'; + +describe('Embedded Database - Bottom Scroll Preservation', () => { + const generateRandomEmail = () => `${uuidv4()}@appflowy.io`; + + beforeEach(() => { + cy.on('uncaught:exception', (err) => { + if (err.message.includes('Minified React error') || + err.message.includes('View not found') || + err.message.includes('No workspace or service found') || + err.message.includes('Cannot resolve a DOM point from Slate point') || + err.message.includes('No range and node found')) { + return false; + } + return true; + }); + + cy.viewport(1280, 720); + }); + + const runScrollPreservationTest = (databaseType: 'grid' | 'board' | 'calendar', selector: string) => { + const testEmail = generateRandomEmail(); + + cy.task('log', `[TEST START] Testing scroll preservation for ${databaseType} at bottom - Test email: ${testEmail}`); + + // Step 1: Login + cy.task('log', '[STEP 1] Visiting login page'); + cy.visit('/login', { failOnStatusCode: false }); + cy.wait(2000); + + const authUtils = new AuthTestUtils(); + cy.task('log', '[STEP 2] Starting authentication'); + authUtils.signInWithTestUrl(testEmail).then(() => { + cy.task('log', '[STEP 3] Authentication successful'); + cy.url({ timeout: 30000 }).should('include', '/app'); + cy.wait(3000); + + // Step 2: Create a new document + cy.task('log', '[STEP 4] Creating new document'); + AddPageSelectors.inlineAddButton().first().as('addBtn'); + cy.get('@addBtn').should('be.visible').click(); + waitForReactUpdate(1000); + cy.get('[role="menuitem"]').first().as('menuItem'); + cy.get('@menuItem').click(); + waitForReactUpdate(1000); + + // Handle the new page modal if it appears + cy.get('body').then(($body) => { + if ($body.find('[data-testid="new-page-modal"]').length > 0) { + cy.task('log', '[STEP 4.1] Handling new page modal'); + ModalSelectors.newPageModal().should('be.visible').within(() => { + ModalSelectors.spaceItemInModal().first().as('spaceItem'); + cy.get('@spaceItem').click(); + waitForReactUpdate(500); + cy.contains('button', 'Add').click(); + }); + cy.wait(3000); + } else { + cy.wait(3000); + } + }); + + // Step 3: Wait for editor to be available and stable + cy.task('log', '[STEP 5] Waiting for editor to be available'); + EditorSelectors.firstEditor().should('exist', { timeout: 15000 }); + waitForReactUpdate(2000); // Give extra time for editor to stabilize + + // Step 4: Add many lines to exceed screen height + cy.task('log', '[STEP 6] Adding multiple lines to exceed screen height'); + + // Click editor to focus it + EditorSelectors.firstEditor().click({ force: true }); + waitForReactUpdate(500); + + // Build text content with 50 lines (increased from 30 to ensure it exceeds screen height) + let textContent = ''; + for (let i = 1; i <= 50; i++) { + textContent += `Line ${i} - This is a longer line of text to ensure we have enough content to scroll and exceed screen height{enter}`; + } + + cy.task('log', '[STEP 6.1] Typing 50 lines of content'); + // Use cy.focused() to type - more stable than re-querying editor element + cy.focused().type(textContent, { delay: 0 }); // Faster typing + + cy.task('log', '[STEP 6.2] Content added successfully'); + waitForReactUpdate(2000); + + // Step 5: Get the scroll container and record initial state + cy.task('log', '[STEP 7] Finding scroll container'); + cy.get('.appflowy-scroll-container').first().as('scrollContainer'); + + // Step 6: Scroll to the bottom + cy.task('log', '[STEP 8] Scrolling to bottom of document'); + cy.get('@scrollContainer').then(($container) => { + const scrollHeight = $container[0].scrollHeight; + const clientHeight = $container[0].clientHeight; + const scrollToPosition = scrollHeight - clientHeight; + + cy.task('log', `[STEP 8.1] Scroll metrics: scrollHeight=${scrollHeight}, clientHeight=${clientHeight}, scrollToPosition=${scrollToPosition}`); + + // Scroll to bottom + cy.get('@scrollContainer').scrollTo(0, scrollToPosition); + waitForReactUpdate(500); + + // Verify we're at the bottom + cy.get('@scrollContainer').then(($cont) => { + const currentScrollTop = $cont[0].scrollTop; + cy.task('log', `[STEP 8.2] Current scroll position after scrolling: ${currentScrollTop}`); + + // Allow some tolerance (within 50px of bottom) + expect(currentScrollTop).to.be.greaterThan(scrollToPosition - 50); + }); + }); + + // Step 7: Store the scroll position before opening slash menu + let scrollPositionBeforeSlashMenu = 0; + + cy.get('@scrollContainer').then(($container) => { + scrollPositionBeforeSlashMenu = $container[0].scrollTop; + cy.task('log', `[STEP 9] Scroll position before opening slash menu: ${scrollPositionBeforeSlashMenu}`); + }); + + // Step 8: Open slash menu at the bottom + cy.task('log', '[STEP 10] Opening slash menu at bottom'); + + // Ensure we click near the bottom of the visible editor area + EditorSelectors.firstEditor().click('bottom', { force: true }); + waitForReactUpdate(500); + + // Type enter to ensure we are on a new line, then slash + EditorSelectors.firstEditor().type('{enter}/', { force: true, delay: 100 }); + waitForReactUpdate(1000); + + // Step 9: Verify slash menu is visible + cy.task('log', '[STEP 11] Verifying slash menu is visible'); + SlashCommandSelectors.slashPanel().should('be.visible'); + + // Step 10: Check that scroll position is preserved after opening slash menu + cy.get('@scrollContainer').then(($container) => { + const scrollAfterSlashMenu = $container[0].scrollTop; + cy.task('log', `[STEP 11.1] Scroll position after opening slash menu: ${scrollAfterSlashMenu}`); + + // Allow some tolerance (within 100px) since the menu might cause minor layout shifts + const scrollDifference = Math.abs(scrollAfterSlashMenu - scrollPositionBeforeSlashMenu); + cy.task('log', `[STEP 11.2] Scroll difference: ${scrollDifference}px`); + + // The scroll should not jump to the top (which would be < 1000) + // It should stay near the bottom + expect(scrollAfterSlashMenu).to.be.greaterThan(scrollPositionBeforeSlashMenu - 200); + }); + + // Step 11: Select database option from slash menu + cy.task('log', `[STEP 12] Selecting ${databaseType} option from slash menu`); + let scrollBeforeDbCreation = 0; + + cy.get('@scrollContainer').then(($container) => { + scrollBeforeDbCreation = $container[0].scrollTop; + cy.task('log', `[STEP 12.1] Scroll position before creating database: ${scrollBeforeDbCreation}`); + }); + + SlashCommandSelectors.slashPanel().within(() => { + // specific handling for board -> kanban mapping + const itemKey = databaseType === 'board' ? 'kanban' : databaseType; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + SlashCommandSelectors.slashMenuItem(getSlashMenuItemName(itemKey as any)).first().as('dbMenuItem'); + cy.get('@dbMenuItem').should('exist').click({ force: true }); + }); + + waitForReactUpdate(2000); + + // Step 12: Verify the modal opened (database opens in a modal) + cy.task('log', '[STEP 13] Verifying database modal opened'); + cy.get('[role="dialog"]', { timeout: 10000 }).should('be.visible'); + + // Step 13: CRITICAL CHECK - Verify scroll position is preserved after creating database + cy.task('log', '[STEP 14] CRITICAL: Verifying scroll position after creating database'); + cy.get('@scrollContainer').then(($container) => { + const scrollAfterDbCreation = $container[0].scrollTop; + const scrollHeight = $container[0].scrollHeight; + const clientHeight = $container[0].clientHeight; + + cy.task('log', `[STEP 14.1] Scroll position after creating ${databaseType}: ${scrollAfterDbCreation}`); + cy.task('log', `[STEP 14.2] scrollHeight: ${scrollHeight}, clientHeight: ${clientHeight}`); + + const scrollDifference = Math.abs(scrollAfterDbCreation - scrollBeforeDbCreation); + cy.task('log', `[STEP 14.3] Scroll difference after ${databaseType} creation: ${scrollDifference}px`); + + // CRITICAL ASSERTION: The document should NOT scroll to the top + // If it scrolled to top, scrollAfterDbCreation would be close to 0 + // We expect it to stay near the bottom + expect(scrollAfterDbCreation).to.be.greaterThan(scrollBeforeDbCreation - 300); + + // Also verify it's not at the very top + expect(scrollAfterDbCreation).to.be.greaterThan(500); + + if (scrollAfterDbCreation < 500) { + cy.task('log', `[CRITICAL FAILURE] Document scrolled to top! Position: ${scrollAfterDbCreation}`); + throw new Error(`Document scrolled to top (position: ${scrollAfterDbCreation}) when creating ${databaseType} at bottom`); + } + + if (scrollDifference > 300) { + cy.task('log', `[WARNING] Large scroll change detected: ${scrollDifference}px`); + } else { + cy.task('log', `[SUCCESS] Scroll position preserved! Difference: ${scrollDifference}px`); + } + }); + + // Step 14: Close the modal and verify final state + cy.task('log', '[STEP 15] Closing database modal'); + cy.get('[role="dialog"]').within(() => { + cy.get('button').first().click(); // Click close button + }); + + waitForReactUpdate(1000); + + // Step 15: Verify the database was actually created in the document + cy.task('log', `[STEP 16] Verifying ${databaseType} database exists in document`); + cy.get('[class*="appflowy-database"]').should('exist'); + + if (selector.startsWith('data-testid')) { + cy.get(`[${selector}]`).should('exist'); + } else { + cy.get(selector).should('exist'); + } + + cy.task('log', `[TEST COMPLETE] Scroll preservation test for ${databaseType} passed successfully`); + }); + }; + + it('should preserve scroll position when creating grid database at bottom', () => { + runScrollPreservationTest('grid', 'data-testid="database-grid"'); + }); + + it('should preserve scroll position when creating board database at bottom', () => { + runScrollPreservationTest('board', '.database-board'); + }); + + it('should preserve scroll position when creating calendar database at bottom', () => { + runScrollPreservationTest('calendar', '.calendar-wrapper'); + }); +}); \ No newline at end of file diff --git a/cypress/e2e/page/create-delete-page.cy.ts b/cypress/e2e/page/create-delete-page.cy.ts index ec93aba86..18ad8233f 100644 --- a/cypress/e2e/page/create-delete-page.cy.ts +++ b/cypress/e2e/page/create-delete-page.cy.ts @@ -73,7 +73,7 @@ describe('Page Create and Delete Tests', () => { ModalSelectors.spaceItemInModal().first().click(); waitForReactUpdate(500); // Click Add button - cy.contains('button', 'Add').click(); + ModalSelectors.addButton().click(); }); // Wait for navigation to the new page diff --git a/cypress/e2e/page/delete-page-verify-trash.cy.ts b/cypress/e2e/page/delete-page-verify-trash.cy.ts index 95f214d96..e74640512 100644 --- a/cypress/e2e/page/delete-page-verify-trash.cy.ts +++ b/cypress/e2e/page/delete-page-verify-trash.cy.ts @@ -74,7 +74,7 @@ describe('Delete Page, Verify in Trash, and Restore Tests', () => { ModalSelectors.spaceItemInModal().first().click(); waitForReactUpdate(500); // Click Add button - cy.contains('button', 'Add').click(); + ModalSelectors.addButton().click(); }); // Wait for navigation to the new page @@ -225,7 +225,7 @@ describe('Delete Page, Verify in Trash, and Restore Tests', () => { // Click the restore button on the first row (our deleted page) TrashSelectors.rows().first().within(() => { // Get the page name before restoring - cy.get('td').first().invoke('text').then((text) => { + TrashSelectors.cell().first().invoke('text').then((text) => { restoredPageName = text.trim() || 'Untitled'; testLog.info(`Restoring page: ${restoredPageName}`); }); diff --git a/cypress/e2e/page/edit-page.cy.ts b/cypress/e2e/page/edit-page.cy.ts index cae2043a2..31faf2750 100644 --- a/cypress/e2e/page/edit-page.cy.ts +++ b/cypress/e2e/page/edit-page.cy.ts @@ -1,6 +1,6 @@ import { AuthTestUtils } from '../../support/auth-utils'; import { TestTool } from '../../support/page-utils'; -import { AddPageSelectors, EditorSelectors, ModalSelectors, PageSelectors, SpaceSelectors, waitForReactUpdate } from '../../support/selectors'; +import { AddPageSelectors, DropdownSelectors, EditorSelectors, ModalSelectors, PageSelectors, SpaceSelectors, waitForReactUpdate } from '../../support/selectors'; import { generateRandomEmail } from '../../support/test-config'; import { testLog } from '../../support/test-helpers'; @@ -63,7 +63,7 @@ describe('Page Edit Tests', () => { waitForReactUpdate(1000); // Select first item (Page) from the menu - cy.get('[role="menuitem"]').first().click(); + DropdownSelectors.menuItem().first().click(); waitForReactUpdate(1000); // Handle the new page modal if it appears (defensive) @@ -73,7 +73,7 @@ describe('Page Edit Tests', () => { ModalSelectors.newPageModal().should('be.visible').within(() => { ModalSelectors.spaceItemInModal().first().click(); waitForReactUpdate(500); - cy.contains('button', 'Add').click(); + ModalSelectors.addButton().click(); }); cy.wait(3000); } diff --git a/cypress/e2e/page/more-page-action.cy.ts b/cypress/e2e/page/more-page-action.cy.ts index f11b22c72..329c4a29d 100644 --- a/cypress/e2e/page/more-page-action.cy.ts +++ b/cypress/e2e/page/more-page-action.cy.ts @@ -1,6 +1,6 @@ import { AuthTestUtils } from '../../support/auth-utils'; import { TestTool } from '../../support/page-utils'; -import { PageSelectors, ModalSelectors, waitForReactUpdate } from '../../support/selectors'; +import { DropdownSelectors, ModalSelectors, PageSelectors, waitForReactUpdate } from '../../support/selectors'; import { generateRandomEmail } from '../../support/test-config'; import { testLog } from '../../support/test-helpers'; @@ -59,10 +59,10 @@ describe('More Page Actions', () => { // Verify core items in ViewActionsPopover // The menu should be open now, verify at least one of the common actions exists - cy.get('[data-slot="dropdown-menu-content"]', { timeout: 5000 }).should('exist'); + DropdownSelectors.content().should('exist'); // Check for common menu items - they might have different test ids or text - cy.get('[data-slot="dropdown-menu-content"]').within(() => { + DropdownSelectors.content().within(() => { // Look for items by text content since test ids might vary cy.contains('Delete').should('exist'); cy.contains('Duplicate').should('exist'); @@ -112,7 +112,7 @@ describe('More Page Actions', () => { testLog.info( 'Clicked more actions button'); // Click on Duplicate option which is available in the dropdown - cy.get('[data-slot="dropdown-menu-content"]').within(() => { + DropdownSelectors.content().within(() => { cy.contains('Duplicate').click(); }); testLog.info( 'Clicked Duplicate option'); @@ -179,10 +179,10 @@ describe('More Page Actions', () => { testLog.info( 'Clicked more actions button'); // Wait for the dropdown menu to be visible - cy.get('[data-slot="dropdown-menu-content"]', { timeout: 5000 }).should('be.visible'); + DropdownSelectors.content().should('be.visible'); // Click on Rename option - simplified approach - cy.get('[data-slot="dropdown-menu-content"]').within(() => { + DropdownSelectors.content().within(() => { cy.contains('Rename').click(); }); diff --git a/cypress/e2e/page/paste/paste-code.cy.ts b/cypress/e2e/page/paste/paste-code.cy.ts index abd3ac1e7..c5495c5a8 100644 --- a/cypress/e2e/page/paste/paste-code.cy.ts +++ b/cypress/e2e/page/paste/paste-code.cy.ts @@ -1,4 +1,5 @@ import { createTestPage, pasteContent } from '../../../support/paste-utils'; +import { BlockSelectors, EditorSelectors } from '../../../support/selectors'; import { testLog } from '../../../support/test-helpers'; describe('Paste Code Block Tests', () => { @@ -16,7 +17,7 @@ describe('Paste Code Block Tests', () => { cy.wait(1000); // CodeBlock component structure: .relative.w-full > pre > code - cy.get('[contenteditable="true"]').find('pre').find('code').should('contain', 'const x = 10'); + EditorSelectors.slateEditor().find('pre').find('code').should('contain', 'const x = 10'); testLog.info('✓ HTML code block pasted successfully'); } @@ -29,7 +30,7 @@ describe('Paste Code Block Tests', () => { cy.wait(1000); - cy.get('[contenteditable="true"]').find('pre').find('code').should('contain', 'function hello'); + EditorSelectors.slateEditor().find('pre').find('code').should('contain', 'function hello'); testLog.info('✓ HTML code block with language pasted successfully'); } @@ -46,8 +47,8 @@ describe('Paste Code Block Tests', () => { cy.wait(1000); - cy.get('[contenteditable="true"]').find('pre').find('code').should('contain', 'def greet'); - cy.get('[contenteditable="true"]').find('pre').find('code').should('contain', 'const greeting'); + EditorSelectors.slateEditor().find('pre').find('code').should('contain', 'def greet'); + EditorSelectors.slateEditor().find('pre').find('code').should('contain', 'const greeting'); testLog.info('✓ HTML multiple language code blocks pasted successfully'); } @@ -61,7 +62,7 @@ describe('Paste Code Block Tests', () => { cy.wait(1000); // AppFlowy renders blockquote as div with data-block-type="quote" - cy.get('[contenteditable="true"]').find('[data-block-type="quote"]').should('contain', 'This is a quoted text'); + EditorSelectors.slateEditor().find(BlockSelectors.blockSelector('quote')).should('contain', 'This is a quoted text'); testLog.info('✓ HTML blockquote pasted successfully'); } @@ -79,8 +80,8 @@ describe('Paste Code Block Tests', () => { cy.wait(1000); - cy.get('[contenteditable="true"]').find('[data-block-type="quote"]').should('contain', 'First level quote'); - cy.get('[contenteditable="true"]').find('[data-block-type="quote"]').should('contain', 'Second level quote'); + EditorSelectors.slateEditor().find(BlockSelectors.blockSelector('quote')).should('contain', 'First level quote'); + EditorSelectors.slateEditor().find(BlockSelectors.blockSelector('quote')).should('contain', 'Second level quote'); testLog.info('✓ HTML nested blockquotes pasted successfully'); } @@ -96,7 +97,7 @@ console.log(x); cy.wait(1000); - cy.get('[contenteditable="true"]').find('pre').find('code').should('contain', 'const x = 10'); + EditorSelectors.slateEditor().find('pre').find('code').should('contain', 'const x = 10'); testLog.info('✓ Markdown code block with language pasted successfully'); } @@ -112,7 +113,7 @@ function hello() { cy.wait(1000); - cy.get('[contenteditable="true"]').find('pre').find('code').should('contain', 'function hello'); + EditorSelectors.slateEditor().find('pre').find('code').should('contain', 'function hello'); testLog.info('✓ Markdown code block without language pasted successfully'); } @@ -125,7 +126,7 @@ function hello() { cy.wait(1000); // Inline code is usually a span with specific style - cy.get('[contenteditable="true"]').find('span.bg-border-primary').should('contain', 'console.log'); + EditorSelectors.slateEditor().find('span.bg-border-primary').should('contain', 'console.log'); testLog.info('✓ Markdown inline code pasted successfully'); } @@ -148,9 +149,9 @@ echo "Hello World" cy.wait(1000); - cy.get('[contenteditable="true"]').find('pre').find('code').should('contain', 'def greet'); - cy.get('[contenteditable="true"]').find('pre').find('code').should('contain', 'const greeting'); - cy.get('[contenteditable="true"]').find('pre').find('code').should('contain', 'echo'); + EditorSelectors.slateEditor().find('pre').find('code').should('contain', 'def greet'); + EditorSelectors.slateEditor().find('pre').find('code').should('contain', 'const greeting'); + EditorSelectors.slateEditor().find('pre').find('code').should('contain', 'echo'); testLog.info('✓ Markdown multiple language code blocks pasted successfully'); } @@ -162,7 +163,7 @@ echo "Hello World" cy.wait(1000); - cy.get('[contenteditable="true"]').find('[data-block-type="quote"]').should('contain', 'This is a quoted text'); + EditorSelectors.slateEditor().find(BlockSelectors.blockSelector('quote')).should('contain', 'This is a quoted text'); testLog.info('✓ Markdown blockquote pasted successfully'); } @@ -176,9 +177,9 @@ echo "Hello World" cy.wait(1000); - cy.get('[contenteditable="true"]').find('[data-block-type="quote"]').should('contain', 'First level quote'); - cy.get('[contenteditable="true"]').find('[data-block-type="quote"]').should('contain', 'Second level quote'); - cy.get('[contenteditable="true"]').find('[data-block-type="quote"]').should('contain', 'Third level quote'); + EditorSelectors.slateEditor().find(BlockSelectors.blockSelector('quote')).should('contain', 'First level quote'); + EditorSelectors.slateEditor().find(BlockSelectors.blockSelector('quote')).should('contain', 'Second level quote'); + EditorSelectors.slateEditor().find(BlockSelectors.blockSelector('quote')).should('contain', 'Third level quote'); testLog.info('✓ Markdown nested blockquotes pasted successfully'); } @@ -190,9 +191,9 @@ echo "Hello World" cy.wait(1000); - cy.get('[contenteditable="true"]').find('[data-block-type="quote"]').find('strong').should('contain', 'Important'); - cy.get('[contenteditable="true"]').find('[data-block-type="quote"]').find('em').should('contain', 'quoted'); - cy.get('[contenteditable="true"]').find('[data-block-type="quote"]').find('span.bg-border-primary').should('contain', 'code'); + EditorSelectors.slateEditor().find(BlockSelectors.blockSelector('quote')).find('strong').should('contain', 'Important'); + EditorSelectors.slateEditor().find(BlockSelectors.blockSelector('quote')).find('em').should('contain', 'quoted'); + EditorSelectors.slateEditor().find(BlockSelectors.blockSelector('quote')).find('span.bg-border-primary').should('contain', 'code'); testLog.info('✓ Markdown blockquote with formatting pasted successfully'); } }); diff --git a/cypress/e2e/page/paste/paste-complex.cy.ts b/cypress/e2e/page/paste/paste-complex.cy.ts index 0ae7d1132..837df697e 100644 --- a/cypress/e2e/page/paste/paste-complex.cy.ts +++ b/cypress/e2e/page/paste/paste-complex.cy.ts @@ -1,4 +1,5 @@ import { createTestPage, pasteContent, verifyEditorContent } from '../../../support/paste-utils'; +import { BlockSelectors, EditorSelectors } from '../../../support/selectors'; import { testLog } from '../../../support/test-helpers'; describe('Paste Complex Content Tests', () => { @@ -29,11 +30,11 @@ describe('Paste Complex Content Tests', () => { cy.wait(2000); // Verify structural elements - cy.get('[contenteditable="true"]').contains('.heading.level-1', 'Project Documentation').scrollIntoView(); - cy.get('[contenteditable="true"]').find('[data-block-type="bulleted_list"]').should('have.length.at.least', 3); - cy.get('[contenteditable="true"]').find('pre').find('code').should('contain', 'console.log'); - cy.get('[contenteditable="true"]').find('[data-block-type="quote"]').should('contain', 'Remember to test'); - cy.get('[contenteditable="true"]').find('span.cursor-pointer.underline').should('contain', 'our website'); + EditorSelectors.slateEditor().contains('.heading.level-1', 'Project Documentation').scrollIntoView(); + EditorSelectors.slateEditor().find(BlockSelectors.blockSelector('bulleted_list')).should('have.length.at.least', 3); + EditorSelectors.slateEditor().find('pre').find('code').should('contain', 'console.log'); + EditorSelectors.slateEditor().find(BlockSelectors.blockSelector('quote')).should('contain', 'Remember to test'); + EditorSelectors.slateEditor().find('span.cursor-pointer.underline').should('contain', 'our website'); testLog.info('✓ Complex document pasted successfully'); } @@ -63,10 +64,10 @@ describe('Paste Complex Content Tests', () => { cy.wait(2000); - cy.get('[contenteditable="true"]').contains('.heading.level-1', 'My Project').scrollIntoView(); - cy.get('[contenteditable="true"]').find('pre').find('code').should('contain', 'npm install'); - cy.get('[contenteditable="true"]').find('[data-block-type="todo_list"]').should('have.length.at.least', 3); - cy.get('[contenteditable="true"]').find('[data-block-type="todo_list"]').filter(':has(.checked)').should('contain', 'Feature 1'); + EditorSelectors.slateEditor().contains('.heading.level-1', 'My Project').scrollIntoView(); + EditorSelectors.slateEditor().find('pre').find('code').should('contain', 'npm install'); + EditorSelectors.slateEditor().find(BlockSelectors.blockSelector('todo_list')).should('have.length.at.least', 3); + EditorSelectors.slateEditor().find(BlockSelectors.blockSelector('todo_list')).filter(':has(.checked)').should('contain', 'Feature 1'); testLog.info('✓ GitHub README pasted successfully'); } @@ -97,11 +98,11 @@ describe('Paste Complex Content Tests', () => { cy.wait(2000); // Verify content exists (markdown may or may not be parsed depending on implementation) - cy.get('[contenteditable="true"]').contains('.heading.level-1', 'Main Title').scrollIntoView(); - cy.get('[contenteditable="true"]').find('strong').should('contain', 'bold'); - cy.get('[contenteditable="true"]').find('[data-block-type="bulleted_list"]').should('contain', 'List item 1'); - cy.get('[contenteditable="true"]').find('pre').find('code').should('contain', 'const x = 10'); - cy.get('[contenteditable="true"]').find('[data-block-type="quote"]').should('contain', 'A quote'); + EditorSelectors.slateEditor().contains('.heading.level-1', 'Main Title').scrollIntoView(); + EditorSelectors.slateEditor().find('strong').should('contain', 'bold'); + EditorSelectors.slateEditor().find(BlockSelectors.blockSelector('bulleted_list')).should('contain', 'List item 1'); + EditorSelectors.slateEditor().find('pre').find('code').should('contain', 'const x = 10'); + EditorSelectors.slateEditor().find(BlockSelectors.blockSelector('quote')).should('contain', 'A quote'); testLog.info('✓ Markdown-like text pasted'); } @@ -141,9 +142,9 @@ describe('Paste Complex Content Tests', () => { cy.get('body').then(() => { // Check that content is present - cy.get('[contenteditable="true"]').contains('.heading.level-1', 'Title').scrollIntoView(); - cy.get('[contenteditable="true"]').find('div').contains('Paragraph').should('exist'); - cy.get('[contenteditable="true"]').find('[data-block-type="bulleted_list"]').should('contain', 'Item 1'); + EditorSelectors.slateEditor().contains('.heading.level-1', 'Title').scrollIntoView(); + EditorSelectors.slateEditor().find('div').contains('Paragraph').should('exist'); + EditorSelectors.slateEditor().find(BlockSelectors.blockSelector('bulleted_list')).should('contain', 'Item 1'); testLog.info('✓ Complex structure verified'); }); diff --git a/cypress/e2e/page/paste/paste-formatting.cy.ts b/cypress/e2e/page/paste/paste-formatting.cy.ts index 299040f77..a3c76f9b1 100644 --- a/cypress/e2e/page/paste/paste-formatting.cy.ts +++ b/cypress/e2e/page/paste/paste-formatting.cy.ts @@ -1,156 +1,215 @@ import { createTestPage, pasteContent } from '../../../support/paste-utils'; +import { EditorSelectors, waitForReactUpdate } from '../../../support/selectors'; import { testLog } from '../../../support/test-helpers'; -describe('Paste Formatting Tests', () => { - it('should paste all formatted content correctly', () => { +describe('Paste Formatting Tests', { testIsolation: false }, () => { + before(() => { createTestPage(); + }); - // --- HTML Inline Formatting --- + beforeEach(() => { + // Clear editor content before each test to ensure a clean state + EditorSelectors.slateEditor().click().type('{selectall}{backspace}'); + waitForReactUpdate(500); + }); + it('should paste HTML inline formatting (Bold, Italic, Underline, Strikethrough)', () => { testLog.info('=== Pasting HTML Bold Text ==='); pasteContent('

This is bold text

', 'This is bold text'); cy.wait(500); - cy.get('[contenteditable="true"]').find('strong').should('contain', 'bold'); + EditorSelectors.slateEditor().find('strong').should('contain', 'bold'); testLog.info('✓ HTML bold text pasted successfully'); + EditorSelectors.slateEditor().click().type('{selectall}{backspace}'); + waitForReactUpdate(200); + testLog.info('=== Pasting HTML Italic Text ==='); pasteContent('

This is italic text

', 'This is italic text'); cy.wait(500); - cy.get('[contenteditable="true"]').find('em').should('contain', 'italic'); + EditorSelectors.slateEditor().find('em').should('contain', 'italic'); testLog.info('✓ HTML italic text pasted successfully'); + EditorSelectors.slateEditor().click().type('{selectall}{backspace}'); + waitForReactUpdate(200); + testLog.info('=== Pasting HTML Underlined Text ==='); pasteContent('

This is underlined text

', 'This is underlined text'); cy.wait(500); - cy.get('[contenteditable="true"]').find('u').should('contain', 'underlined'); + EditorSelectors.slateEditor().find('u').should('contain', 'underlined'); testLog.info('✓ HTML underlined text pasted successfully'); + EditorSelectors.slateEditor().click().type('{selectall}{backspace}'); + waitForReactUpdate(200); + testLog.info('=== Pasting HTML Strikethrough Text ==='); pasteContent('

This is strikethrough text

', 'This is strikethrough text'); cy.wait(500); - cy.get('[contenteditable="true"]').find('s').should('contain', 'strikethrough'); + EditorSelectors.slateEditor().find('s').should('contain', 'strikethrough'); testLog.info('✓ HTML strikethrough text pasted successfully'); + }); + it('should paste HTML special formatting (Code, Link, Mixed, Nested)', () => { testLog.info('=== Pasting HTML Inline Code ==='); pasteContent('

Use the console.log() function

', 'Use the console.log() function'); cy.wait(500); - // Code is rendered as a span with specific classes in AppFlowy - cy.get('[contenteditable="true"]').find('span.bg-border-primary').should('contain', 'console.log()'); + EditorSelectors.slateEditor().find('span.bg-border-primary').should('contain', 'console.log()'); testLog.info('✓ HTML inline code pasted successfully'); + EditorSelectors.slateEditor().click().type('{selectall}{backspace}'); + waitForReactUpdate(200); + testLog.info('=== Pasting HTML Mixed Formatting ==='); pasteContent('

Text with bold, italic, and underline

', 'Text with bold, italic, and underline'); cy.wait(500); - cy.get('[contenteditable="true"]').find('strong').should('contain', 'bold'); - cy.get('[contenteditable="true"]').find('em').should('contain', 'italic'); - cy.get('[contenteditable="true"]').find('u').should('contain', 'underline'); + EditorSelectors.slateEditor().find('strong').should('contain', 'bold'); + EditorSelectors.slateEditor().find('em').should('contain', 'italic'); + EditorSelectors.slateEditor().find('u').should('contain', 'underline'); testLog.info('✓ HTML mixed formatting pasted successfully'); + EditorSelectors.slateEditor().click().type('{selectall}{backspace}'); + waitForReactUpdate(200); + testLog.info('=== Pasting HTML Link ==='); pasteContent('

Visit AppFlowy website

', 'Visit AppFlowy website'); cy.wait(500); - // Links are rendered as spans with cursor-pointer and underline classes in AppFlowy - cy.get('[contenteditable="true"]').find('span.cursor-pointer.underline').should('contain', 'AppFlowy'); + EditorSelectors.slateEditor().find('span.cursor-pointer.underline').should('contain', 'AppFlowy'); testLog.info('✓ HTML link pasted successfully'); + EditorSelectors.slateEditor().click().type('{selectall}{backspace}'); + waitForReactUpdate(200); + testLog.info('=== Pasting HTML Nested Formatting ==='); pasteContent('

Text with bold and italic nested

', 'Text with bold and italic nested'); cy.wait(500); - cy.get('[contenteditable="true"]').find('strong').should('contain', 'bold and'); - cy.get('[contenteditable="true"]').find('strong').find('em').should('contain', 'italic'); + EditorSelectors.slateEditor().find('strong').should('contain', 'bold and'); + EditorSelectors.slateEditor().find('strong').find('em').should('contain', 'italic'); testLog.info('✓ HTML nested formatting pasted successfully'); + EditorSelectors.slateEditor().click().type('{selectall}{backspace}'); + waitForReactUpdate(200); + testLog.info('=== Pasting HTML Complex Nested Formatting ==='); pasteContent('

Bold, italic, and underlined text

', 'Bold, italic, and underlined text'); cy.wait(500); - // Check strict nesting: strong > em > u - cy.get('[contenteditable="true"]') + EditorSelectors.slateEditor() .find('strong') .find('em') .find('u') .should('contain', 'Bold, italic, and underlined'); testLog.info('✓ HTML complex nested formatting pasted successfully'); + }); - // --- Markdown Inline Formatting --- - + it('should paste Markdown inline formatting (Bold, Italic, Strikethrough, Code)', () => { testLog.info('=== Pasting Markdown Bold Text (asterisk) ==='); pasteContent('', 'This is **bold** text'); cy.wait(500); - cy.get('[contenteditable="true"]').find('strong').should('contain', 'bold'); + EditorSelectors.slateEditor().find('strong').should('contain', 'bold'); testLog.info('✓ Markdown bold text (asterisk) pasted successfully'); + EditorSelectors.slateEditor().click().type('{selectall}{backspace}'); + waitForReactUpdate(200); + testLog.info('=== Pasting Markdown Bold Text (underscore) ==='); pasteContent('', 'This is __bold__ text'); cy.wait(500); - cy.get('[contenteditable="true"]').find('strong').should('contain', 'bold'); + EditorSelectors.slateEditor().find('strong').should('contain', 'bold'); testLog.info('✓ Markdown bold text (underscore) pasted successfully'); + EditorSelectors.slateEditor().click().type('{selectall}{backspace}'); + waitForReactUpdate(200); + testLog.info('=== Pasting Markdown Italic Text (asterisk) ==='); pasteContent('', 'This is *italic* text'); cy.wait(500); - cy.get('[contenteditable="true"]').find('em').should('contain', 'italic'); + EditorSelectors.slateEditor().find('em').should('contain', 'italic'); testLog.info('✓ Markdown italic text (asterisk) pasted successfully'); + EditorSelectors.slateEditor().click().type('{selectall}{backspace}'); + waitForReactUpdate(200); + testLog.info('=== Pasting Markdown Italic Text (underscore) ==='); pasteContent('', 'This is _italic_ text'); cy.wait(500); - cy.get('[contenteditable="true"]').find('em').should('contain', 'italic'); + EditorSelectors.slateEditor().find('em').should('contain', 'italic'); testLog.info('✓ Markdown italic text (underscore) pasted successfully'); + EditorSelectors.slateEditor().click().type('{selectall}{backspace}'); + waitForReactUpdate(200); + testLog.info('=== Pasting Markdown Strikethrough Text ==='); pasteContent('', 'This is ~~strikethrough~~ text'); cy.wait(500); - cy.get('[contenteditable="true"]').find('s').should('contain', 'strikethrough'); + EditorSelectors.slateEditor().find('s').should('contain', 'strikethrough'); testLog.info('✓ Markdown strikethrough text pasted successfully'); + EditorSelectors.slateEditor().click().type('{selectall}{backspace}'); + waitForReactUpdate(200); + testLog.info('=== Pasting Markdown Inline Code ==='); pasteContent('', 'Use the `console.log()` function'); cy.wait(500); - cy.get('[contenteditable="true"]').find('span.bg-border-primary').should('contain', 'console.log()'); + EditorSelectors.slateEditor().find('span.bg-border-primary').should('contain', 'console.log()'); testLog.info('✓ Markdown inline code pasted successfully'); + }); + it('should paste Markdown complex/mixed formatting (Mixed, Link, Nested)', () => { testLog.info('=== Pasting Markdown Mixed Formatting ==='); pasteContent('', 'Text with **bold**, *italic*, ~~strikethrough~~, and `code`'); cy.wait(500); - cy.get('[contenteditable="true"]').find('strong').should('contain', 'bold'); - cy.get('[contenteditable="true"]').find('em').should('contain', 'italic'); - cy.get('[contenteditable="true"]').find('s').should('contain', 'strikethrough'); - cy.get('[contenteditable="true"]').find('span.bg-border-primary').should('contain', 'code'); + EditorSelectors.slateEditor().find('strong').should('contain', 'bold'); + EditorSelectors.slateEditor().find('em').should('contain', 'italic'); + EditorSelectors.slateEditor().find('s').should('contain', 'strikethrough'); + EditorSelectors.slateEditor().find('span.bg-border-primary').should('contain', 'code'); testLog.info('✓ Markdown mixed formatting pasted successfully'); + EditorSelectors.slateEditor().click().type('{selectall}{backspace}'); + waitForReactUpdate(200); + testLog.info('=== Pasting Markdown Link ==='); pasteContent('', 'Visit [AppFlowy](https://appflowy.io) website'); cy.wait(500); - cy.get('[contenteditable="true"]').find('span.cursor-pointer.underline').should('contain', 'AppFlowy'); + EditorSelectors.slateEditor().find('span.cursor-pointer.underline').should('contain', 'AppFlowy'); testLog.info('✓ Markdown link pasted successfully'); + EditorSelectors.slateEditor().click().type('{selectall}{backspace}'); + waitForReactUpdate(200); + testLog.info('=== Pasting Markdown Nested Formatting ==='); pasteContent('', 'Text with **bold and *italic* nested**'); cy.wait(500); - cy.get('[contenteditable="true"]').find('strong').should('contain', 'bold and'); - cy.get('[contenteditable="true"]').find('strong').find('em').should('contain', 'italic'); + EditorSelectors.slateEditor().find('strong').should('contain', 'bold and'); + EditorSelectors.slateEditor().find('strong').find('em').should('contain', 'italic'); testLog.info('✓ Markdown nested formatting pasted successfully'); + EditorSelectors.slateEditor().click().type('{selectall}{backspace}'); + waitForReactUpdate(200); + testLog.info('=== Pasting Markdown Complex Nested Formatting ==='); pasteContent('', '***Bold and italic*** text'); cy.wait(500); // In Markdown, ***text*** is usually bold AND italic. - cy.get('[contenteditable="true"]').find('strong').find('em').should('contain', 'Bold and italic'); + EditorSelectors.slateEditor().find('strong').find('em').should('contain', 'Bold and italic'); testLog.info('✓ Markdown complex nested formatting pasted successfully'); + EditorSelectors.slateEditor().click().type('{selectall}{backspace}'); + waitForReactUpdate(200); + testLog.info('=== Pasting Markdown Link with Formatting ==='); pasteContent('', 'Visit [**AppFlowy** website](https://appflowy.io) for more'); cy.wait(500); - cy.get('[contenteditable="true"]').find('span.cursor-pointer.underline').find('strong').should('contain', 'AppFlowy'); + EditorSelectors.slateEditor().find('span.cursor-pointer.underline').find('strong').should('contain', 'AppFlowy'); testLog.info('✓ Markdown link with formatting pasted successfully'); + EditorSelectors.slateEditor().click().type('{selectall}{backspace}'); + waitForReactUpdate(200); + testLog.info('=== Pasting Markdown Multiple Inline Code ==='); pasteContent('', 'Compare `const` vs `let` vs `var` in JavaScript'); cy.wait(500); - cy.get('[contenteditable="true"]').find('span.bg-border-primary').should('have.length.at.least', 3); - cy.get('[contenteditable="true"]').find('span.bg-border-primary').should('contain', 'const'); - cy.get('[contenteditable="true"]').find('span.bg-border-primary').should('contain', 'let'); - cy.get('[contenteditable="true"]').find('span.bg-border-primary').should('contain', 'var'); + EditorSelectors.slateEditor().find('span.bg-border-primary').should('have.length.at.least', 3); + EditorSelectors.slateEditor().find('span.bg-border-primary').should('contain', 'const'); + EditorSelectors.slateEditor().find('span.bg-border-primary').should('contain', 'let'); + EditorSelectors.slateEditor().find('span.bg-border-primary').should('contain', 'var'); testLog.info('✓ Markdown multiple inline code pasted successfully'); }); -}); +}); \ No newline at end of file diff --git a/cypress/e2e/page/paste/paste-headings.cy.ts b/cypress/e2e/page/paste/paste-headings.cy.ts index 52c4338f3..3e6ec969e 100644 --- a/cypress/e2e/page/paste/paste-headings.cy.ts +++ b/cypress/e2e/page/paste/paste-headings.cy.ts @@ -1,4 +1,5 @@ import { createTestPage, pasteContent } from '../../../support/paste-utils'; +import { EditorSelectors } from '../../../support/selectors'; import { testLog } from '../../../support/test-helpers'; describe('Paste Heading Tests', () => { @@ -16,7 +17,7 @@ describe('Paste Heading Tests', () => { cy.wait(1000); // AppFlowy renders H1 as div.heading.level-1 - cy.get('[contenteditable="true"]').find('.heading.level-1').should('contain', 'Main Heading'); + EditorSelectors.slateEditor().find('.heading.level-1').should('contain', 'Main Heading'); testLog.info('✓ HTML H1 pasted successfully'); // Add a new line to separate content, targeting the last editor or focused editor @@ -32,7 +33,7 @@ describe('Paste Heading Tests', () => { cy.wait(1000); - cy.get('[contenteditable="true"]').find('.heading.level-2').should('contain', 'Section Title'); + EditorSelectors.slateEditor().find('.heading.level-2').should('contain', 'Section Title'); testLog.info('✓ HTML H2 pasted successfully'); // Add a new line to separate content @@ -52,9 +53,9 @@ describe('Paste Heading Tests', () => { cy.wait(1000); - cy.get('[contenteditable="true"]').find('.heading.level-1').should('contain', 'Main Title'); - cy.get('[contenteditable="true"]').find('.heading.level-2').should('contain', 'Subtitle'); - cy.get('[contenteditable="true"]').find('.heading.level-3').should('contain', 'Section'); + EditorSelectors.slateEditor().find('.heading.level-1').should('contain', 'Main Title'); + EditorSelectors.slateEditor().find('.heading.level-2').should('contain', 'Subtitle'); + EditorSelectors.slateEditor().find('.heading.level-3').should('contain', 'Section'); testLog.info('✓ HTML multiple headings pasted successfully'); // Add a new line to separate content @@ -70,7 +71,7 @@ describe('Paste Heading Tests', () => { cy.wait(1000); - cy.get('[contenteditable="true"]').find('.heading.level-1').should('contain', 'Main Heading'); + EditorSelectors.slateEditor().find('.heading.level-1').should('contain', 'Main Heading'); testLog.info('✓ Markdown H1 pasted successfully'); // Add a new line to separate content @@ -85,7 +86,7 @@ describe('Paste Heading Tests', () => { cy.wait(1000); - cy.get('[contenteditable="true"]').find('.heading.level-2').should('contain', 'Section Title'); + EditorSelectors.slateEditor().find('.heading.level-2').should('contain', 'Section Title'); testLog.info('✓ Markdown H2 pasted successfully'); // Add a new line to separate content @@ -103,10 +104,10 @@ describe('Paste Heading Tests', () => { cy.wait(1000); - cy.get('[contenteditable="true"]').find('.heading.level-3').should('contain', 'Heading 3'); - cy.get('[contenteditable="true"]').find('.heading.level-4').should('contain', 'Heading 4'); - cy.get('[contenteditable="true"]').find('.heading.level-5').should('contain', 'Heading 5'); - cy.get('[contenteditable="true"]').find('.heading.level-6').should('contain', 'Heading 6'); + EditorSelectors.slateEditor().find('.heading.level-3').should('contain', 'Heading 3'); + EditorSelectors.slateEditor().find('.heading.level-4').should('contain', 'Heading 4'); + EditorSelectors.slateEditor().find('.heading.level-5').should('contain', 'Heading 5'); + EditorSelectors.slateEditor().find('.heading.level-6').should('contain', 'Heading 6'); testLog.info('✓ Markdown H3-H6 pasted successfully'); // Add a new line to separate content @@ -123,9 +124,9 @@ describe('Paste Heading Tests', () => { cy.wait(1000); - cy.get('[contenteditable="true"]').find('.heading.level-1').should('contain', 'Heading with').find('strong').should('contain', 'bold'); - cy.get('[contenteditable="true"]').find('.heading.level-2').should('contain', 'Heading with').find('em').should('contain', 'italic'); - cy.get('[contenteditable="true"]').find('.heading.level-3').should('contain', 'Heading with').find('span.bg-border-primary').should('contain', 'code'); + EditorSelectors.slateEditor().find('.heading.level-1').should('contain', 'Heading with').find('strong').should('contain', 'bold'); + EditorSelectors.slateEditor().find('.heading.level-2').should('contain', 'Heading with').find('em').should('contain', 'italic'); + EditorSelectors.slateEditor().find('.heading.level-3').should('contain', 'Heading with').find('span.bg-border-primary').should('contain', 'code'); testLog.info('✓ Markdown headings with formatting pasted successfully'); } }); diff --git a/cypress/e2e/page/paste/paste-lists.cy.ts b/cypress/e2e/page/paste/paste-lists.cy.ts index b609e13a7..e09dc3a2f 100644 --- a/cypress/e2e/page/paste/paste-lists.cy.ts +++ b/cypress/e2e/page/paste/paste-lists.cy.ts @@ -1,4 +1,5 @@ import { createTestPage, pasteContent } from '../../../support/paste-utils'; +import { BlockSelectors, EditorSelectors } from '../../../support/selectors'; import { testLog } from '../../../support/test-helpers'; describe('Paste List Tests', () => { @@ -22,14 +23,14 @@ describe('Paste List Tests', () => { cy.wait(1000); // AppFlowy renders bulleted lists as div elements with data-block-type="bulleted_list" - cy.get('[data-block-type="bulleted_list"]').should('have.length.at.least', 3); + BlockSelectors.blockByType('bulleted_list').should('have.length.at.least', 3); cy.contains('First item').should('exist'); cy.contains('Second item').should('exist'); cy.contains('Third item').should('exist'); testLog.info('✓ HTML unordered list pasted successfully'); // Exit list mode - cy.get('[contenteditable="true"]').last().type('{enter}{enter}'); + EditorSelectors.slateEditor().last().type('{enter}{enter}'); } { @@ -48,14 +49,14 @@ describe('Paste List Tests', () => { cy.wait(1000); // AppFlowy renders numbered lists as div elements with data-block-type="numbered_list" - cy.get('[data-block-type="numbered_list"]').should('have.length.at.least', 3); + BlockSelectors.blockByType('numbered_list').should('have.length.at.least', 3); cy.contains('Step one').should('exist'); cy.contains('Step two').should('exist'); cy.contains('Step three').should('exist'); testLog.info('✓ HTML ordered list pasted successfully'); // Exit list mode - cy.get('[contenteditable="true"]').last().type('{enter}{enter}'); + EditorSelectors.slateEditor().last().type('{enter}{enter}'); } { @@ -74,13 +75,13 @@ describe('Paste List Tests', () => { // AppFlowy renders todo lists as div elements with data-block-type="todo_list" // The checked state is rendered as a class on the inner div - cy.get('[data-block-type="todo_list"]').should('have.length.at.least', 2); + BlockSelectors.blockByType('todo_list').should('have.length.at.least', 2); cy.contains('Completed task').should('exist'); cy.contains('Incomplete task').should('exist'); testLog.info('✓ HTML todo list pasted successfully'); // Exit list mode - cy.get('[contenteditable="true"]').last().type('{enter}{enter}'); + EditorSelectors.slateEditor().last().type('{enter}{enter}'); } // Markdown Lists @@ -94,12 +95,12 @@ describe('Paste List Tests', () => { cy.wait(1000); - cy.get('[data-block-type="bulleted_list"]').should('have.length.at.least', 3); + BlockSelectors.blockByType('bulleted_list').should('have.length.at.least', 3); cy.contains('First item').should('exist'); testLog.info('✓ Markdown unordered list (dash) pasted successfully'); // Exit list mode - cy.get('[contenteditable="true"]').last().type('{enter}{enter}'); + EditorSelectors.slateEditor().last().type('{enter}{enter}'); } { @@ -112,12 +113,12 @@ describe('Paste List Tests', () => { cy.wait(1000); - cy.get('[data-block-type="bulleted_list"]').should('have.length.at.least', 3); + BlockSelectors.blockByType('bulleted_list').should('have.length.at.least', 3); cy.contains('Apple').should('exist'); testLog.info('✓ Markdown unordered list (asterisk) pasted successfully'); // Exit list mode - cy.get('[contenteditable="true"]').last().type('{enter}{enter}'); + EditorSelectors.slateEditor().last().type('{enter}{enter}'); } { @@ -130,12 +131,12 @@ describe('Paste List Tests', () => { cy.wait(1000); - cy.get('[data-block-type="numbered_list"]').should('have.length.at.least', 3); + BlockSelectors.blockByType('numbered_list').should('have.length.at.least', 3); cy.contains('First step').should('exist'); testLog.info('✓ Markdown ordered list pasted successfully'); // Exit list mode - cy.get('[contenteditable="true"]').last().type('{enter}{enter}'); + EditorSelectors.slateEditor().last().type('{enter}{enter}'); } { @@ -148,13 +149,13 @@ describe('Paste List Tests', () => { cy.wait(1000); - cy.get('[data-block-type="todo_list"]').should('have.length.at.least', 3); + BlockSelectors.blockByType('todo_list').should('have.length.at.least', 3); cy.contains('Completed task').should('exist'); cy.contains('Incomplete task').should('exist'); testLog.info('✓ Markdown task list pasted successfully'); // Exit list mode - cy.get('[contenteditable="true"]').last().type('{enter}{enter}'); + EditorSelectors.slateEditor().last().type('{enter}{enter}'); } { @@ -169,12 +170,12 @@ describe('Paste List Tests', () => { cy.wait(1000); - cy.get('[data-block-type="bulleted_list"]').should('contain', 'Parent item 1'); - cy.get('[data-block-type="bulleted_list"]').should('contain', 'Child item 1.1'); + BlockSelectors.blockByType('bulleted_list').should('contain', 'Parent item 1'); + BlockSelectors.blockByType('bulleted_list').should('contain', 'Child item 1.1'); testLog.info('✓ Markdown nested lists pasted successfully'); // Exit list mode - cy.get('[contenteditable="true"]').last().type('{enter}{enter}'); + EditorSelectors.slateEditor().last().type('{enter}{enter}'); } { @@ -194,7 +195,7 @@ describe('Paste List Tests', () => { testLog.info('✓ Markdown list with formatting pasted successfully'); // Exit list mode - cy.get('[contenteditable="true"]').last().type('{enter}{enter}'); + EditorSelectors.slateEditor().last().type('{enter}{enter}'); } { @@ -216,14 +217,14 @@ Please let us know your feedback.`; cy.contains('We are excited to announce').should('exist'); // Verify special bullets are converted to BulletedListBlock - cy.get('[data-block-type="bulleted_list"]').should('contain', 'Fast performance'); - cy.get('[data-block-type="bulleted_list"]').should('contain', 'Secure encryption'); - cy.get('[data-block-type="bulleted_list"]').should('contain', 'Offline mode'); + BlockSelectors.blockByType('bulleted_list').should('contain', 'Fast performance'); + BlockSelectors.blockByType('bulleted_list').should('contain', 'Secure encryption'); + BlockSelectors.blockByType('bulleted_list').should('contain', 'Offline mode'); testLog.info('✓ Generic text with special bullets pasted successfully'); // Exit list mode - cy.get('[contenteditable="true"]').last().type('{enter}{enter}'); + EditorSelectors.slateEditor().last().type('{enter}{enter}'); } { @@ -246,9 +247,9 @@ Please let us know your feedback.`; // Check that "Private" does not have leading/trailing newlines in the text content // We can check this by ensuring it doesn't create extra blocks or lines - cy.get('[data-block-type="bulleted_list"]').contains('Private').should('exist'); - cy.get('[data-block-type="bulleted_list"]').contains('Customizable').should('exist'); - cy.get('[data-block-type="bulleted_list"]').contains('Self-hostable').should('exist'); + BlockSelectors.blockByType('bulleted_list').contains('Private').should('exist'); + BlockSelectors.blockByType('bulleted_list').contains('Customizable').should('exist'); + BlockSelectors.blockByType('bulleted_list').contains('Self-hostable').should('exist'); testLog.info('✓ HTML list with inner newlines pasted successfully'); } diff --git a/cypress/e2e/page/paste/paste-plain-text.cy.ts b/cypress/e2e/page/paste/paste-plain-text.cy.ts index 7df2d5906..e2936617c 100644 --- a/cypress/e2e/page/paste/paste-plain-text.cy.ts +++ b/cypress/e2e/page/paste/paste-plain-text.cy.ts @@ -1,4 +1,5 @@ import { createTestPage, pasteContent } from '../../../support/paste-utils'; +import { EditorSelectors } from '../../../support/selectors'; import { testLog } from '../../../support/test-helpers'; describe('Paste Plain Text Tests', () => { @@ -12,29 +13,12 @@ describe('Paste Plain Text Tests', () => { testLog.info('=== Pasting Plain Text ==='); // Use type for plain text fallback if paste doesn't work in test env - cy.get('[contenteditable="true"]').then($editors => { - // Look for the main editor (not the title) - let editorFound = false; - $editors.each((index: number, el: HTMLElement) => { - const $el = Cypress.$(el); - // Skip title inputs - if (!$el.attr('data-testid')?.includes('title') && !$el.hasClass('editor-title')) { - cy.wrap(el).click().type(plainText); - editorFound = true; - return false; // break the loop - } - }); - - if (!editorFound && $editors.length > 0) { - // Fallback: use the last contenteditable element - cy.wrap($editors.last()).click().type(plainText); - } - }); + EditorSelectors.slateEditor().click().type(plainText); // Verify content cy.wait(2000); // Use more robust selector to verify content - cy.get('[contenteditable="true"]').should('contain', plainText); + EditorSelectors.slateEditor().should('contain', plainText); testLog.info('✓ Plain text pasted successfully'); } @@ -46,7 +30,7 @@ describe('Paste Plain Text Tests', () => { cy.wait(500); // Should not crash - cy.get('[contenteditable="true"]').should('exist'); + EditorSelectors.slateEditor().should('exist'); testLog.info('✓ Empty paste handled gracefully'); } @@ -58,30 +42,12 @@ describe('Paste Plain Text Tests', () => { testLog.info('=== Pasting Long Content ==='); // Use type with a small delay to avoid Slate DOM sync errors - cy.get('[contenteditable="true"]').then($editors => { - // Look for the main editor (not the title) - let editorFound = false; - $editors.each((_index: number, el: HTMLElement) => { - const $el = Cypress.$(el); - // Skip title inputs - if (!$el.attr('data-testid')?.includes('title') && !$el.hasClass('editor-title')) { - // Use a small delay (10ms) to prevent Slate from getting out of sync - cy.wrap(el).click().type(longText, { delay: 10 }); - editorFound = true; - return false; // break the loop - } - }); - - if (!editorFound && $editors.length > 0) { - // Fallback - cy.wrap($editors.last()).click().type(longText, { delay: 10 }); - } - }); + EditorSelectors.slateEditor().click().type(longText, { delay: 10 }); cy.wait(1000); // Check for content in any editable element - cy.get('[contenteditable="true"]').should('contain', 'Lorem ipsum'); + EditorSelectors.slateEditor().should('contain', 'Lorem ipsum'); testLog.info('✓ Long content pasted successfully'); } }); diff --git a/cypress/e2e/page/paste/paste-tables.cy.ts b/cypress/e2e/page/paste/paste-tables.cy.ts index 481a29a29..3d4150472 100644 --- a/cypress/e2e/page/paste/paste-tables.cy.ts +++ b/cypress/e2e/page/paste/paste-tables.cy.ts @@ -1,4 +1,5 @@ import { createTestPage, pasteContent } from '../../../support/paste-utils'; +import { EditorSelectors } from '../../../support/selectors'; import { testLog } from '../../../support/test-helpers'; describe('Paste Table Tests', () => { @@ -35,10 +36,10 @@ describe('Paste Table Tests', () => { cy.wait(1500); // AppFlowy uses SimpleTable which renders as a table within a specific container - cy.get('[contenteditable="true"]').find('.simple-table').find('table').should('exist'); - cy.get('[contenteditable="true"]').find('.simple-table').find('tr').should('have.length.at.least', 3); - cy.get('[contenteditable="true"]').find('.simple-table').contains('Name'); - cy.get('[contenteditable="true"]').find('.simple-table').contains('John'); + EditorSelectors.slateEditor().find('.simple-table').find('table').should('exist'); + EditorSelectors.slateEditor().find('.simple-table').find('tr').should('have.length.at.least', 3); + EditorSelectors.slateEditor().find('.simple-table').contains('Name'); + EditorSelectors.slateEditor().find('.simple-table').contains('John'); testLog.info('✓ HTML table pasted successfully'); } @@ -70,8 +71,8 @@ describe('Paste Table Tests', () => { cy.wait(1500); - cy.get('[contenteditable="true"]').find('.simple-table').find('strong').should('contain', 'Authentication'); - cy.get('[contenteditable="true"]').find('.simple-table').find('em').should('contain', 'Complete'); + EditorSelectors.slateEditor().find('.simple-table').find('strong').should('contain', 'Authentication'); + EditorSelectors.slateEditor().find('.simple-table').find('em').should('contain', 'Complete'); testLog.info('✓ HTML table with formatting pasted successfully'); } @@ -88,9 +89,9 @@ describe('Paste Table Tests', () => { cy.wait(1500); - cy.get('[contenteditable="true"]').find('.simple-table').should('contain', 'Product'); - cy.get('[contenteditable="true"]').find('.simple-table').should('contain', 'Apple'); - cy.get('[contenteditable="true"]').find('.simple-table').should('contain', 'Banana'); + EditorSelectors.slateEditor().find('.simple-table').should('contain', 'Product'); + EditorSelectors.slateEditor().find('.simple-table').should('contain', 'Apple'); + EditorSelectors.slateEditor().find('.simple-table').should('contain', 'Banana'); testLog.info('✓ Markdown table pasted successfully'); } @@ -105,8 +106,8 @@ describe('Paste Table Tests', () => { cy.wait(1500); - cy.get('[contenteditable="true"]').find('.simple-table').should('contain', 'Left Align'); - cy.get('[contenteditable="true"]').find('.simple-table').should('contain', 'Center Align'); + EditorSelectors.slateEditor().find('.simple-table').should('contain', 'Left Align'); + EditorSelectors.slateEditor().find('.simple-table').should('contain', 'Center Align'); testLog.info('✓ Markdown table with alignment pasted successfully'); } @@ -121,8 +122,8 @@ describe('Paste Table Tests', () => { cy.wait(1500); - cy.get('[contenteditable="true"]').find('.simple-table').find('strong').should('contain', 'Bold Feature'); - cy.get('[contenteditable="true"]').find('.simple-table').find('em').should('contain', 'In Progress'); + EditorSelectors.slateEditor().find('.simple-table').find('strong').should('contain', 'Bold Feature'); + EditorSelectors.slateEditor().find('.simple-table').find('em').should('contain', 'In Progress'); testLog.info('✓ Markdown table with inline formatting pasted successfully'); } @@ -139,9 +140,9 @@ Bob\tbob@example.com\t555-5678`; // TSV might be pasted as a table or plain text depending on implementation // Assuming table based on previous tests - cy.get('[contenteditable="true"]').find('.simple-table').should('exist'); - cy.get('[contenteditable="true"]').find('.simple-table').should('contain', 'Alice'); - cy.get('[contenteditable="true"]').find('.simple-table').should('contain', 'alice@example.com'); + EditorSelectors.slateEditor().find('.simple-table').should('exist'); + EditorSelectors.slateEditor().find('.simple-table').should('contain', 'Alice'); + EditorSelectors.slateEditor().find('.simple-table').should('contain', 'alice@example.com'); testLog.info('✓ TSV data pasted successfully'); } }); diff --git a/cypress/e2e/page/publish-page.cy.ts b/cypress/e2e/page/publish-page.cy.ts index 6102d8b8c..51bcb0e33 100644 --- a/cypress/e2e/page/publish-page.cy.ts +++ b/cypress/e2e/page/publish-page.cy.ts @@ -1,6 +1,6 @@ import { AuthTestUtils } from '../../support/auth-utils'; import { TestTool } from '../../support/page-utils'; -import { PageSelectors, ShareSelectors, SidebarSelectors } from '../../support/selectors'; +import { EditorSelectors, PageSelectors, ShareSelectors, SidebarSelectors } from '../../support/selectors'; import { generateRandomEmail, logAppFlowyEnvironment } from '../../support/test-config'; import { testLog } from '../../support/test-helpers'; @@ -394,20 +394,7 @@ describe('Publish Page Test', () => { // Add initial content to the page testLog.info('Adding initial content to page'); - cy.get('[contenteditable="true"]').then(($editors) => { - let editorFound = false; - $editors.each((index: number, el: HTMLElement) => { - const $el = Cypress.$(el); - if (!$el.attr('data-testid')?.includes('title') && !$el.hasClass('editor-title')) { - cy.wrap(el).click({ force: true }).clear().type(initialContent, { force: true }); - editorFound = true; - return false; - } - }); - if (!editorFound && $editors.length > 0) { - cy.wrap($editors.last()).click({ force: true }).clear().type(initialContent, { force: true }); - } - }); + EditorSelectors.firstEditor().click({ force: true }).clear().type(initialContent, { force: true }); cy.wait(2000); // First publish @@ -448,20 +435,7 @@ describe('Publish Page Test', () => { // Modify the page content testLog.info('Modifying page content'); - cy.get('[contenteditable="true"]').then(($editors) => { - let editorFound = false; - $editors.each((index: number, el: HTMLElement) => { - const $el = Cypress.$(el); - if (!$el.attr('data-testid')?.includes('title') && !$el.hasClass('editor-title')) { - cy.wrap(el).click({ force: true }).clear().type(updatedContent, { force: true }); - editorFound = true; - return false; - } - }); - if (!editorFound && $editors.length > 0) { - cy.wrap($editors.last()).click({ force: true }).clear().type(updatedContent, { force: true }); - } - }); + EditorSelectors.firstEditor().click({ force: true }).clear().type(updatedContent, { force: true }); cy.wait(5000); // Wait for content to save // Republish to sync the updated content diff --git a/cypress/e2e/page/share-page.cy.ts b/cypress/e2e/page/share-page.cy.ts index 828afdaf6..fc72adc5a 100644 --- a/cypress/e2e/page/share-page.cy.ts +++ b/cypress/e2e/page/share-page.cy.ts @@ -1,6 +1,6 @@ import { AuthTestUtils } from '../../support/auth-utils'; import { TestTool } from '../../support/page-utils'; -import { PageSelectors, SidebarSelectors, ShareSelectors, waitForReactUpdate } from '../../support/selectors'; +import { DropdownSelectors, PageSelectors, SidebarSelectors, ShareSelectors, waitForReactUpdate } from '../../support/selectors'; import { generateRandomEmail, logAppFlowyEnvironment } from '../../support/test-config'; import { testLog } from '../../support/test-helpers'; @@ -66,7 +66,7 @@ describe('Share Page Test', () => { testLog.info( `Inviting user B: ${userBEmail}`); ShareSelectors.sharePopover().within(() => { // Find the input field inside the email-tag-input container - cy.get('[data-slot="email-tag-input"]') + ShareSelectors.emailTagInput() .find('input[type="text"]') .should('be.visible') .clear() @@ -75,14 +75,14 @@ describe('Share Page Test', () => { waitForReactUpdate(500); // Press Enter to add the email tag - cy.get('[data-slot="email-tag-input"]') + ShareSelectors.emailTagInput() .find('input[type="text"]') .type('{enter}', { force: true }); waitForReactUpdate(1000); // Click the Invite button to send the invitation - cy.contains('button', /invite/i) + ShareSelectors.inviteButton() .should('be.visible') .should('not.be.disabled') .click({ force: true }); @@ -197,17 +197,17 @@ describe('Share Page Test', () => { }); ShareSelectors.sharePopover().within(() => { - cy.get('[data-slot="email-tag-input"]') + ShareSelectors.emailTagInput() .find('input[type="text"]') .should('be.visible') .clear() .type(userBEmail, { force: true }); waitForReactUpdate(500); - cy.get('[data-slot="email-tag-input"]') + ShareSelectors.emailTagInput() .find('input[type="text"]') .type('{enter}', { force: true }); waitForReactUpdate(1000); - cy.contains('button', /invite/i) + ShareSelectors.inviteButton() .should('be.visible') .should('not.be.disabled') .click({ force: true }); @@ -308,20 +308,20 @@ describe('Share Page Test', () => { const emails = [userBEmail, userCEmail, userDEmail]; emails.forEach((email, index) => { - cy.get('[data-slot="email-tag-input"]') + ShareSelectors.emailTagInput() .find('input[type="text"]') .should('be.visible') .clear() .type(email, { force: true }); waitForReactUpdate(300); - cy.get('[data-slot="email-tag-input"]') + ShareSelectors.emailTagInput() .find('input[type="text"]') .type('{enter}', { force: true }); waitForReactUpdate(500); }); // Click Invite button - cy.contains('button', /invite/i) + ShareSelectors.inviteButton() .should('be.visible') .should('not.be.disabled') .click({ force: true }); @@ -384,7 +384,7 @@ describe('Share Page Test', () => { waitForReactUpdate(500); // Select "Can edit" from dropdown - cy.get('[role="menu"]').within(() => { + DropdownSelectors.menu().within(() => { cy.contains(/can edit|edit/i).click({ force: true }); }); waitForReactUpdate(500); @@ -403,7 +403,7 @@ describe('Share Page Test', () => { .find('input[type="text"]') .type('{enter}', { force: true }); waitForReactUpdate(1000); - cy.contains('button', /invite/i) + ShareSelectors.inviteButton() .should('be.visible') .should('not.be.disabled') .click({ force: true }); @@ -455,17 +455,17 @@ describe('Share Page Test', () => { // Invite user B ShareSelectors.sharePopover().within(() => { - cy.get('[data-slot="email-tag-input"]') + ShareSelectors.emailTagInput() .find('input[type="text"]') .should('be.visible') .clear() .type(userBEmail, { force: true }); waitForReactUpdate(500); - cy.get('[data-slot="email-tag-input"]') + ShareSelectors.emailTagInput() .find('input[type="text"]') .type('{enter}', { force: true }); waitForReactUpdate(1000); - cy.contains('button', /invite/i) + ShareSelectors.inviteButton() .should('be.visible') .should('not.be.disabled') .click({ force: true }); @@ -533,19 +533,19 @@ describe('Share Page Test', () => { testLog.info( `Inviting users: ${userBEmail}, ${userCEmail}`); ShareSelectors.sharePopover().within(() => { [userBEmail, userCEmail].forEach((email) => { - cy.get('[data-slot="email-tag-input"]') + ShareSelectors.emailTagInput() .find('input[type="text"]') .should('be.visible') .clear() .type(email, { force: true }); waitForReactUpdate(300); - cy.get('[data-slot="email-tag-input"]') + ShareSelectors.emailTagInput() .find('input[type="text"]') .type('{enter}', { force: true }); waitForReactUpdate(500); }); - cy.contains('button', /invite/i) + ShareSelectors.inviteButton() .should('be.visible') .should('not.be.disabled') .click({ force: true }); @@ -675,21 +675,20 @@ describe('Share Page Test', () => { // Invite user B testLog.info( `Inviting user B: ${userBEmail}`); ShareSelectors.sharePopover().within(() => { - cy.get('[data-slot="email-tag-input"]') + ShareSelectors.emailTagInput() .find('input[type="text"]') .should('be.visible') .clear() .type(userBEmail, { force: true }); waitForReactUpdate(500); - cy.get('[data-slot="email-tag-input"]') + ShareSelectors.emailTagInput() .find('input[type="text"]') .type('{enter}', { force: true }); waitForReactUpdate(1000); - cy.contains('button', /invite/i) - .should('be.visible') - .should('not.be.disabled') - .click({ force: true }); - }); + ShareSelectors.inviteButton() + .should('be.visible') + .should('not.be.disabled') + .click({ force: true }); }); waitForReactUpdate(3000); @@ -784,21 +783,20 @@ describe('Share Page Test', () => { // Invite user B testLog.info( `Inviting user B: ${userBEmail}`); ShareSelectors.sharePopover().within(() => { - cy.get('[data-slot="email-tag-input"]') + ShareSelectors.emailTagInput() .find('input[type="text"]') .should('be.visible') .clear() .type(userBEmail, { force: true }); waitForReactUpdate(500); - cy.get('[data-slot="email-tag-input"]') + ShareSelectors.emailTagInput() .find('input[type="text"]') .type('{enter}', { force: true }); waitForReactUpdate(1000); - cy.contains('button', /invite/i) - .should('be.visible') - .should('not.be.disabled') - .click({ force: true }); - }); + ShareSelectors.inviteButton() + .should('be.visible') + .should('not.be.disabled') + .click({ force: true }); }); waitForReactUpdate(3000); diff --git a/cypress/support/paste-utils.ts b/cypress/support/paste-utils.ts index ba161dd11..268386145 100644 --- a/cypress/support/paste-utils.ts +++ b/cypress/support/paste-utils.ts @@ -1,6 +1,6 @@ import { AuthTestUtils } from './auth-utils'; import { TestTool } from './page-utils'; -import { AddPageSelectors, ModalSelectors, PageSelectors, SpaceSelectors, waitForReactUpdate } from './selectors'; +import { AddPageSelectors, DropdownSelectors, EditorSelectors, ModalSelectors, PageSelectors, SpaceSelectors, waitForReactUpdate } from './selectors'; import { generateRandomEmail } from './test-config'; import { testLog } from './test-helpers'; @@ -14,10 +14,10 @@ import { testLog } from './test-helpers'; */ export const pasteContent = (html: string, plainText: string) => { // Wait for editors to be available - cy.get('[contenteditable="true"]').should('have.length.at.least', 1); + EditorSelectors.slateEditor().should('have.length.at.least', 1); // Find the index of the main editor (not the title) - cy.get('[contenteditable="true"]').then($editors => { + EditorSelectors.slateEditor().then($editors => { let targetIndex = -1; $editors.each((index: number, el: HTMLElement) => { @@ -39,10 +39,10 @@ export const pasteContent = (html: string, plainText: string) => { // Click the editor to ensure it's active. Splitting this from the next block // handles cases where click might trigger a re-render. - cy.get('[contenteditable="true"]').eq(targetIndex).click({ force: true }); + EditorSelectors.slateEditor().eq(targetIndex).click({ force: true }); // Re-query to get the fresh element for Slate instance extraction - cy.get('[contenteditable="true"]').eq(targetIndex).then(($el) => { + EditorSelectors.slateEditor().eq(targetIndex).then(($el) => { const targetEditor = $el[0]; // Access the Slate editor instance and call insertData directly @@ -177,7 +177,7 @@ export const createTestPage = () => { waitForReactUpdate(1000); // Select first item (Page) from the menu - cy.get('[role="menuitem"]').first().click(); + DropdownSelectors.menuItem().first().click(); waitForReactUpdate(1000); // Handle the new page modal if it appears (defensive) @@ -187,7 +187,7 @@ export const createTestPage = () => { ModalSelectors.newPageModal().should('be.visible').within(() => { ModalSelectors.spaceItemInModal().first().click(); waitForReactUpdate(500); - cy.contains('button', 'Add').click(); + ModalSelectors.addButton().click(); }); cy.wait(3000); } @@ -211,7 +211,7 @@ export const createTestPage = () => { * Verify content exists in the editor using DevTools */ export const verifyEditorContent = (expectedContent: string) => { - cy.get('[contenteditable="true"]').then($editors => { + EditorSelectors.slateEditor().then($editors => { // Find the main content editor (not the title) let editorHTML = ''; $editors.each((_index: number, el: HTMLElement) => { diff --git a/cypress/support/selectors.ts b/cypress/support/selectors.ts index 37fb48325..d7016dfc1 100644 --- a/cypress/support/selectors.ts +++ b/cypress/support/selectors.ts @@ -144,6 +144,20 @@ export const ModalSelectors = { // Rename modal inputs renameInput: () => cy.get(byTestId('rename-modal-input')), renameSaveButton: () => cy.get(byTestId('rename-modal-save')), + + // Generic dialog selectors + dialogContainer: () => cy.get('.MuiDialog-container'), + dialogRole: () => cy.get('[role="dialog"]'), + addButton: () => cy.contains('button', 'Add'), +}; + +/** + * Dropdown/Menu selectors + */ +export const DropdownSelectors = { + content: (options?: any) => cy.get('[data-slot="dropdown-menu-content"]', options), + menu: (options?: any) => cy.get('[role="menu"]', options), + menuItem: (options?: any) => cy.get('[role="menuitem"]', options), }; /** @@ -164,6 +178,10 @@ export const ShareSelectors = { // Share popover sharePopover: () => cy.get(byTestId('share-popover')), + + // Share inputs + emailTagInput: () => cy.get('[data-slot="email-tag-input"]'), + inviteButton: () => cy.contains('button', /invite/i), // Publish tab button publishTabButton: () => cy.get(byTestId('publish-tab-button')), @@ -235,6 +253,7 @@ export const TrashSelectors = { sidebarTrashButton: () => cy.get(byTestId('sidebar-trash-button')), table: () => cy.get(byTestId('trash-table')), rows: () => cy.get(byTestId('trash-table-row')), + cell: () => cy.get('td'), restoreButton: () => cy.get(byTestId('trash-restore-button')), deleteButton: () => cy.get(byTestId('trash-delete-button')), }; @@ -370,6 +389,9 @@ export const SlashCommandSelectors = { // Slash menu item slashMenuItem: (name: string) => cy.get('[data-testid^="slash-menu-"]').filter(`:contains("${name}")`), + heading1: () => cy.get(byTestId('slash-menu-heading1')), + bulletedList: () => cy.get(byTestId('slash-menu-bulletedList')), + // Database selection modal (legacy - kept for backward compatibility) promptModal: () => cy.get(byTestId('prompt-modal')), @@ -566,6 +588,11 @@ export const EditorSelectors = { underlineButton: () => cy.get(byTestId('toolbar-underline-button')), strikethroughButton: () => cy.get(byTestId('toolbar-strikethrough-button')), codeButton: () => cy.get(byTestId('toolbar-code-button')), + linkButton: () => cy.get(byTestId('link-button')), + textColorButton: () => cy.get(byTestId('text-color-button')), + bgColorButton: () => cy.get(byTestId('bg-color-button')), + headingButton: () => cy.get(byTestId('heading-button')), + heading1Button: () => cy.get(byTestId('heading-1-button')), }; /** @@ -690,6 +717,18 @@ export const AvatarUiSelectors = { image: () => cy.get(byTestId('avatar-image')), }; +/** + * Block-related selectors + */ +export const BlockSelectors = { + dragHandle: () => cy.get(byTestId('drag-block')), + hoverControls: () => cy.get(byTestId('hover-controls')), + slashMenuGrid: () => cy.get(byTestId('slash-menu-grid')), + blockByType: (type: string) => cy.get(`[data-block-type="${type}"]`), + blockSelector: (type: string) => `[data-block-type="${type}"]`, + allBlocks: () => cy.get('[data-block-type]'), +}; + export function waitForReactUpdate(ms: number = 500) { return cy.wait(ms); } diff --git a/package.json b/package.json index 7c200aa6f..67481f893 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "@atlaskit/primitives": "^5.5.3", "@emoji-mart/data": "^1.1.2", "@emoji-mart/react": "^1.1.1", + "@emotion/is-prop-valid": "^1.4.0", "@emotion/react": "^11.10.6", "@emotion/styled": "^11.10.6", "@floating-ui/react": "^0.26.27", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8115d8f44..5229f1aab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,9 @@ importers: '@emoji-mart/react': specifier: ^1.1.1 version: 1.1.1(emoji-mart@5.6.0)(react@18.3.1) + '@emotion/is-prop-valid': + specifier: ^1.4.0 + version: 1.4.0 '@emotion/react': specifier: ^11.10.6 version: 11.14.0(@types/react@18.3.21)(react@18.3.1) @@ -199,7 +202,7 @@ importers: version: 3.3.0 framer-motion: specifier: ^12.6.3 - version: 12.12.1(@emotion/is-prop-valid@1.3.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 12.12.1(@emotion/is-prop-valid@1.4.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) google-protobuf: specifier: ^3.15.12 version: 3.21.4 @@ -1716,8 +1719,8 @@ packages: '@emotion/hash@0.9.2': resolution: {integrity: sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==} - '@emotion/is-prop-valid@1.3.1': - resolution: {integrity: sha512-/ACwoqx7XQi9knQs/G0qKvv5teDMhD7bXYns9N/wM8ah8iNb8jZ2uNO0YOgiq2o2poIvVtJS2YALasQuMSQ7Kw==} + '@emotion/is-prop-valid@1.4.0': + resolution: {integrity: sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==} '@emotion/memoize@0.9.0': resolution: {integrity: sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==} @@ -11073,7 +11076,7 @@ snapshots: '@emotion/hash@0.9.2': {} - '@emotion/is-prop-valid@1.3.1': + '@emotion/is-prop-valid@1.4.0': dependencies: '@emotion/memoize': 0.9.0 @@ -11109,7 +11112,7 @@ snapshots: dependencies: '@babel/runtime': 7.27.1 '@emotion/babel-plugin': 11.13.5 - '@emotion/is-prop-valid': 1.3.1 + '@emotion/is-prop-valid': 1.4.0 '@emotion/react': 11.14.0(@types/react@18.3.21)(react@18.3.1) '@emotion/serialize': 1.3.3 '@emotion/use-insertion-effect-with-fallbacks': 1.2.0(react@18.3.1) @@ -15517,13 +15520,13 @@ snapshots: fraction.js@4.3.7: {} - framer-motion@12.12.1(@emotion/is-prop-valid@1.3.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + framer-motion@12.12.1(@emotion/is-prop-valid@1.4.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: motion-dom: 12.12.1 motion-utils: 12.12.1 tslib: 2.8.1 optionalDependencies: - '@emotion/is-prop-valid': 1.3.1 + '@emotion/is-prop-valid': 1.4.0 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) diff --git a/src/application/database-yjs/dispatch.ts b/src/application/database-yjs/dispatch.ts index 3ee8c0ca5..8108901d1 100644 --- a/src/application/database-yjs/dispatch.ts +++ b/src/application/database-yjs/dispatch.ts @@ -51,7 +51,6 @@ import { getOptionsFromRow, initialDatabaseRow } from '@/application/database-yj import { generateRowMeta, getMetaIdMap, getMetaJSON, getRowKey } from '@/application/database-yjs/row_meta'; import { useBoardLayoutSettings, useCalendarLayoutSetting, useDatabaseViewLayout, useFieldSelector, useFieldType } from '@/application/database-yjs/selector'; import { executeOperations } from '@/application/slate-yjs/utils/yjs'; -import { applyYDoc } from '@/application/ydoc/apply'; import { DatabaseViewLayout, DateFormat, @@ -87,6 +86,7 @@ import { YSharedRoot, } from '@/application/types'; import { DefaultTimeSetting } from '@/application/user-metadata'; +import { applyYDoc } from '@/application/ydoc/apply'; import { useCurrentUser } from '@/components/main/app.hooks'; export function useResizeColumnWidthDispatch() { diff --git a/src/application/slate-yjs/command/index.ts b/src/application/slate-yjs/command/index.ts index da2310364..cd8ef5426 100644 --- a/src/application/slate-yjs/command/index.ts +++ b/src/application/slate-yjs/command/index.ts @@ -755,6 +755,14 @@ export const CustomEditor = { return; } + // Skip focus and selection for database blocks (Grid, Board, Calendar) + // as they open in a modal and don't need cursor positioning + const isDatabaseBlock = [BlockType.GridBlock, BlockType.BoardBlock, BlockType.CalendarBlock].includes(type); + + if (isDatabaseBlock) { + return newBlockId; + } + try { const entry = findSlateEntryByBlockId(editor, newBlockId); diff --git a/src/application/slate-yjs/plugins/withHistory.ts b/src/application/slate-yjs/plugins/withHistory.ts index 8294fffa3..c90d533eb 100644 --- a/src/application/slate-yjs/plugins/withHistory.ts +++ b/src/application/slate-yjs/plugins/withHistory.ts @@ -52,7 +52,7 @@ export function withYHistory(editor: T): T & YHistoryEditor } e.undoManager = new Y.UndoManager(document, { - trackedOrigins: new Set([CollabOrigin.Local, null]), + trackedOrigins: new Set([CollabOrigin.Local, CollabOrigin.LocalManual, null]), captureTimeout: 200, }); diff --git a/src/application/slate-yjs/utils/applyToSlate.ts b/src/application/slate-yjs/utils/applyToSlate.ts index d41aca87c..7224e1818 100644 --- a/src/application/slate-yjs/utils/applyToSlate.ts +++ b/src/application/slate-yjs/utils/applyToSlate.ts @@ -13,6 +13,11 @@ import { YBlock, YjsEditorKey } from '@/application/types'; // eslint-disable-next-line @typescript-eslint/no-explicit-any type BlockMapEvent = YMapEvent; +interface YBlockChange { + action: string; + oldValue: unknown; +} + /** * Translates Yjs events to Slate editor operations * This function processes different types of Yjs events and applies corresponding changes to the Slate editor @@ -126,8 +131,9 @@ function applyBlocksYEvent(editor: YjsEditor, event: BlockMapEvent) { }); const keyPath: Record = {}; + const updates: { key: string; action: string; value: YBlockChange }[] = []; - keysChanged?.forEach((key: string, index: number) => { + keysChanged?.forEach((key: string) => { const value = keys.get(key); if (!value) { @@ -135,19 +141,30 @@ function applyBlocksYEvent(editor: YjsEditor, event: BlockMapEvent) { return; } - console.debug(`📋 Processing block change ${index + 1}/${keysChanged.size}:`, { + updates.push({ key, action: value.action, value: value as YBlockChange }); + }); + + // Sort updates: delete first, then add/update + updates.sort((a, b) => { + if (a.action === 'delete' && b.action !== 'delete') return -1; + if (a.action !== 'delete' && b.action === 'delete') return 1; + return 0; + }); + + updates.forEach(({ key, action, value }, index) => { + console.debug(`📋 Processing block change ${index + 1}/${updates.length}:`, { key, - action: value.action, + action, oldValue: value.oldValue, }); - if (value.action === 'add') { + if (action === 'add') { console.debug(`➕ Adding new block: ${key}`); handleNewBlock(editor, key, keyPath); - } else if (value.action === 'delete') { + } else if (action === 'delete') { console.debug(`🗑️ Deleting block: ${key}`); handleDeleteNode(editor, key); - } else if (value.action === 'update') { + } else if (action === 'update') { console.debug(`🔄 Updating block: ${key}`); // TODO: Implement block update logic } diff --git a/src/application/slate-yjs/utils/yjs.ts b/src/application/slate-yjs/utils/yjs.ts index e6d3b832c..924b38fe6 100644 --- a/src/application/slate-yjs/utils/yjs.ts +++ b/src/application/slate-yjs/utils/yjs.ts @@ -113,13 +113,18 @@ export function assertDocExists(sharedRoot: YSharedRoot): YDoc { return doc; } -export function executeOperations(sharedRoot: YSharedRoot, operations: (() => void)[], operationName: string) { +export function executeOperations( + sharedRoot: YSharedRoot, + operations: (() => void)[], + operationName: string, + origin?: unknown +) { console.time(operationName); const doc = assertDocExists(sharedRoot); doc.transact(() => { operations.forEach((op) => op()); - }); + }, origin); console.timeEnd(operationName); } diff --git a/src/application/types.ts b/src/application/types.ts index 64d73c4f0..0734b76b7 100644 --- a/src/application/types.ts +++ b/src/application/types.ts @@ -729,6 +729,8 @@ export enum CollabOrigin { Local = 'local', // from remote changes and never sync to remote. Remote = 'remote', + // from local changes manually applied to Yjs + LocalManual = 'local_manual', } export interface PublishViewPayload { diff --git a/src/components/app/ViewModal.tsx b/src/components/app/ViewModal.tsx index bfc2508fa..1c4b65cce 100644 --- a/src/components/app/ViewModal.tsx +++ b/src/components/app/ViewModal.tsx @@ -289,9 +289,11 @@ function ViewModal({ viewId, open, onClose }: { viewId?: string; open: boolean; onClose={handleClose} fullWidth={true} keepMounted={false} - disableAutoFocus={false} + disableAutoFocus={true} disableEnforceFocus={false} disableRestoreFocus={true} + disableScrollLock={true} + disablePortal={false} TransitionComponent={Transition} PaperProps={{ ref, diff --git a/src/components/app/hooks/useViewNavigation.ts b/src/components/app/hooks/useViewNavigation.ts index a7a0af087..4f5759f68 100644 --- a/src/components/app/hooks/useViewNavigation.ts +++ b/src/components/app/hooks/useViewNavigation.ts @@ -1,4 +1,5 @@ import { useCallback } from 'react'; + import { SCROLL_DELAY, SCROLL_FALLBACK_DELAY } from './constants'; export const useDatabaseViewNavigation = ( diff --git a/src/components/app/hooks/useViewSync.ts b/src/components/app/hooks/useViewSync.ts index 63cd04b27..f641a35f4 100644 --- a/src/components/app/hooks/useViewSync.ts +++ b/src/components/app/hooks/useViewSync.ts @@ -1,6 +1,8 @@ -import { YDatabaseView } from '@/application/types'; import { useCallback } from 'react'; import * as Y from 'yjs'; + +import { YDatabaseView } from '@/application/types'; + import { SYNC_MAX_ATTEMPTS, SYNC_POLL_INTERVAL } from './constants'; export const useDatabaseViewSync = (views: Y.Map | undefined) => { diff --git a/src/components/as-template/icons.tsx b/src/components/as-template/icons.tsx index 7682489ae..3facda5a4 100644 --- a/src/components/as-template/icons.tsx +++ b/src/components/as-template/icons.tsx @@ -8,16 +8,16 @@ import { ReactComponent as Engineering } from '@/assets/icons/engineering.svg'; import { ReactComponent as Facebook } from '@/assets/icons/facebook.svg'; import { ReactComponent as GraduationCap } from '@/assets/icons/graduation_cap.svg'; import { ReactComponent as Instagram } from '@/assets/icons/instagram.svg'; -import { ReactComponent as Twitter } from '@/assets/icons/twitter.svg'; -import { ReactComponent as Tiktok } from '@/assets/icons/tiktok.svg'; import { ReactComponent as LinkedInIcon } from '@/assets/icons/linkedin.svg'; -import { ReactComponent as Startup } from '@/assets/icons/startup.svg'; -import { ReactComponent as User } from '@/assets/icons/user.svg'; -import { ReactComponent as UsersThree } from '@/assets/icons/users.svg'; import { ReactComponent as Management } from '@/assets/icons/management.svg'; import { ReactComponent as Marketing } from '@/assets/icons/marketing.svg'; -import { ReactComponent as Sales } from '@/assets/icons/sales.svg'; import { ReactComponent as Doc } from '@/assets/icons/page.svg'; +import { ReactComponent as Sales } from '@/assets/icons/sales.svg'; +import { ReactComponent as Startup } from '@/assets/icons/startup.svg'; +import { ReactComponent as Tiktok } from '@/assets/icons/tiktok.svg'; +import { ReactComponent as Twitter } from '@/assets/icons/twitter.svg'; +import { ReactComponent as User } from '@/assets/icons/user.svg'; +import { ReactComponent as UsersThree } from '@/assets/icons/users.svg'; import { ReactComponent as Wiki } from '@/assets/icons/wiki.svg'; import { ReactComponent as Youtube } from '@/assets/icons/youtube.svg'; diff --git a/src/components/chat/components/ai-writer/view-tree/index.tsx b/src/components/chat/components/ai-writer/view-tree/index.tsx index c817d9562..9c86afa52 100644 --- a/src/components/chat/components/ai-writer/view-tree/index.tsx +++ b/src/components/chat/components/ai-writer/view-tree/index.tsx @@ -11,10 +11,10 @@ import { useCheckboxTree } from '@/components/chat/hooks/use-checkbox-tree'; import { MESSAGE_VARIANTS } from '@/components/chat/lib/animations'; import { searchViews } from '@/components/chat/lib/views'; import { View } from '@/components/chat/types'; +import { useWriterContext } from '@/components/chat/writer/context'; import { Button } from '@/components/ui/button'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Separator } from '@/components/ui/separator'; -import { useWriterContext } from '@/components/chat/writer/context'; import { Spaces } from './spaces'; diff --git a/src/components/chat/components/ai-writer/writing-input.tsx b/src/components/chat/components/ai-writer/writing-input.tsx index f3e3a9d78..ed8e266e2 100644 --- a/src/components/chat/components/ai-writer/writing-input.tsx +++ b/src/components/chat/components/ai-writer/writing-input.tsx @@ -8,18 +8,18 @@ import { ReactComponent as ImageTextIcon } from '@/assets/icons/text_image.svg'; import { ModelSelector } from '@/components/chat/components/chat-input/model-selector'; import { PromptModal } from '@/components/chat/components/chat-input/prompt-modal'; import { FormatGroup } from '@/components/chat/components/ui/format-group'; -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; - -import { WritingMore } from '../ai-writer/writing-more'; import LoadingDots from '@/components/chat/components/ui/loading-dots'; import { Textarea } from '@/components/chat/components/ui/textarea'; -import { cn } from '@/lib/utils'; import { usePromptModal } from '@/components/chat/provider/prompt-modal-provider'; import { ChatInputMode } from '@/components/chat/types'; import { AiPrompt } from '@/components/chat/types/prompt'; import { useWriterContext } from '@/components/chat/writer/context'; import { Button } from '@/components/ui/button'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { cn } from '@/lib/utils'; + import { ViewTree } from '../ai-writer/view-tree'; +import { WritingMore } from '../ai-writer/writing-more'; const MAX_HEIGHT = 200; // Prevent focus on page load and cause the page to scroll diff --git a/src/components/chat/components/chat-input/related-views/index.tsx b/src/components/chat/components/chat-input/related-views/index.tsx index 212ae24d9..98a97f3e4 100644 --- a/src/components/chat/components/chat-input/related-views/index.tsx +++ b/src/components/chat/components/chat-input/related-views/index.tsx @@ -8,8 +8,8 @@ import { useViewLoader } from '@/components/chat'; import LoadingDots from '@/components/chat/components/ui/loading-dots'; import { SearchInput } from '@/components/chat/components/ui/search-input'; import { useChatSettingsLoader } from '@/components/chat/hooks/use-chat-settings-loader'; -import { MESSAGE_VARIANTS } from '@/components/chat/lib/animations'; import { useCheckboxTree } from '@/components/chat/hooks/use-checkbox-tree'; +import { MESSAGE_VARIANTS } from '@/components/chat/lib/animations'; import { searchViews } from '@/components/chat/lib/views'; import { View } from '@/components/chat/types'; import { Button } from '@/components/ui/button'; diff --git a/src/components/chat/components/chat-messages/assistant-message.tsx b/src/components/chat/components/chat-messages/assistant-message.tsx index a86a14ebc..938b92e4a 100644 --- a/src/components/chat/components/chat-messages/assistant-message.tsx +++ b/src/components/chat/components/chat-messages/assistant-message.tsx @@ -5,13 +5,12 @@ import { useTranslation } from 'react-i18next'; import { ReactComponent as Error } from '@/assets/icons/error.svg'; import { Alert, AlertDescription } from '@/components/chat/components/ui/alert'; import LoadingDots from '@/components/chat/components/ui/loading-dots'; - - import { useMessagesHandlerContext } from '@/components/chat/provider/messages-handler-provider'; import { useChatMessagesContext } from '@/components/chat/provider/messages-provider'; import { useResponseFormatContext } from '@/components/chat/provider/response-format-provider'; import { useSuggestionsContext } from '@/components/chat/provider/suggestions-provider'; import { ChatInputMode } from '@/components/chat/types'; + import { AnswerMd } from '../chat-messages/answer-md'; import { MessageActions } from '../chat-messages/message-actions'; import MessageSources from '../chat-messages/message-sources'; diff --git a/src/components/database/Database.tsx b/src/components/database/Database.tsx index d39505335..eccce395a 100644 --- a/src/components/database/Database.tsx +++ b/src/components/database/Database.tsx @@ -247,7 +247,7 @@ function Database(props: Database2Props) { }, []); if (!rowDocMap || !viewId) { - return null; + return
; } return ( diff --git a/src/components/database/components/grid/grid-table/GridVirtualizer.tsx b/src/components/database/components/grid/grid-table/GridVirtualizer.tsx index c9af18f2a..3cb76c82c 100644 --- a/src/components/database/components/grid/grid-table/GridVirtualizer.tsx +++ b/src/components/database/components/grid/grid-table/GridVirtualizer.tsx @@ -21,7 +21,7 @@ function GridVirtualizer({ columns }: { columns: RenderColumn[] }) { const { handleResizeStart, isResizing } = useColumnResize(columns); const { isDocumentBlock, paddingEnd } = useDatabaseContext(); - const { parentRef, virtualizer, columnVirtualizer, scrollMarginTop } = useGridVirtualizer({ + const { parentRef, virtualizer, columnVirtualizer, scrollMarginTop, isReady } = useGridVirtualizer({ data, columns, }); @@ -148,6 +148,7 @@ function GridVirtualizer({ columns }: { columns: RenderColumn[] }) { style={{ height: virtualizer.getTotalSize(), position: 'relative', + opacity: isReady ? 1 : 0, // Hide content until parent offset is stable to prevent scroll jumps }} > {rowItems.map((row) => { diff --git a/src/components/database/components/grid/grid-table/useGridDnd.ts b/src/components/database/components/grid/grid-table/useGridDnd.ts index 64529204b..10e6d6c91 100644 --- a/src/components/database/components/grid/grid-table/useGridDnd.ts +++ b/src/components/database/components/grid/grid-table/useGridDnd.ts @@ -9,8 +9,8 @@ import { } from '@atlaskit/pragmatic-drag-and-drop-hitbox/util/get-reorder-destination-index'; import * as liveRegion from '@atlaskit/pragmatic-drag-and-drop-live-region'; import { Virtualizer } from '@tanstack/react-virtual'; - import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + import { useDatabaseViewId, useReadOnly } from '@/application/database-yjs'; import { useReorderColumnDispatch, useReorderRowDispatch } from '@/application/database-yjs/dispatch'; import { diff --git a/src/components/database/components/grid/grid-table/useGridVirtualizer.ts b/src/components/database/components/grid/grid-table/useGridVirtualizer.ts index 97b869152..aaeec8e76 100644 --- a/src/components/database/components/grid/grid-table/useGridVirtualizer.ts +++ b/src/components/database/components/grid/grid-table/useGridVirtualizer.ts @@ -24,6 +24,7 @@ export function useGridVirtualizer({ data, columns }: { columns: RenderColumn[]; const [parentOffset, setParentOffset] = useState(0); const rafIdRef = useRef(); const isInitialMountRef = useRef(true); + const [isReady, setIsReady] = useState(false); const getScrollElement = useCallback(() => { if (!parentRef.current) return null; @@ -81,6 +82,7 @@ export function useGridVirtualizer({ data, columns }: { columns: RenderColumn[]; if (parentOffsetRef.current === null) { parentOffsetRef.current = nextOffset; setParentOffset(nextOffset); + setIsReady(true); logDebug('[GridVirtualizer] initial parent offset set', { nextOffset, isInitialMount: isInitialMountRef.current, @@ -104,11 +106,13 @@ export function useGridVirtualizer({ data, columns }: { columns: RenderColumn[]; isInitialMount: isInitialMountRef.current, }); isInitialMountRef.current = false; + setIsReady(true); return; } parentOffsetRef.current = nextOffset; setParentOffset(nextOffset); + setIsReady(true); logDebug('[GridVirtualizer] parent offset updated', { nextOffset, previous: parentOffset, @@ -202,5 +206,6 @@ export function useGridVirtualizer({ data, columns }: { columns: RenderColumn[]; virtualizer, columnVirtualizer, scrollMarginTop: parentOffset, + isReady, }; } diff --git a/src/components/database/components/tabs/DatabaseTabItem.tsx b/src/components/database/components/tabs/DatabaseTabItem.tsx index 5fa6290f1..838aea8b8 100644 --- a/src/components/database/components/tabs/DatabaseTabItem.tsx +++ b/src/components/database/components/tabs/DatabaseTabItem.tsx @@ -1,3 +1,6 @@ +import { memo, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + import { DatabaseViewLayout, View, @@ -14,8 +17,6 @@ import { } from '@/components/ui/dropdown-menu'; import { TabLabel, TabsTrigger } from '@/components/ui/tabs'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; -import { memo, useMemo } from 'react'; -import { useTranslation } from 'react-i18next'; export interface DatabaseTabItemProps { viewId: string; diff --git a/src/components/database/components/tabs/DatabaseViewTabs.tsx b/src/components/database/components/tabs/DatabaseViewTabs.tsx index 1aa1f22da..83782e810 100644 --- a/src/components/database/components/tabs/DatabaseViewTabs.tsx +++ b/src/components/database/components/tabs/DatabaseViewTabs.tsx @@ -1,3 +1,7 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import * as Y from 'yjs'; + +import { YDatabaseView } from '@/application/types'; import { ReactComponent as ChevronLeft } from '@/assets/icons/alt_arrow_left.svg'; import { ReactComponent as ChevronRight } from '@/assets/icons/alt_arrow_right.svg'; import { AFScroller } from '@/components/_shared/scroller'; @@ -6,9 +10,7 @@ import { DatabaseTabItem } from '@/components/database/components/tabs/DatabaseT import { useTabScroller } from '@/components/database/components/tabs/useTabScroller'; import { Button } from '@/components/ui/button'; import { Tabs, TabsList } from '@/components/ui/tabs'; -import { useCallback, useEffect, useRef, useState } from 'react'; -import * as Y from 'yjs'; -import { YDatabaseView } from '@/application/types'; + export interface DatabaseViewTabsProps { viewIds: string[]; diff --git a/src/components/database/fullcalendar/FullCalendar.tsx b/src/components/database/fullcalendar/FullCalendar.tsx index 9f67f3239..411540692 100644 --- a/src/components/database/fullcalendar/FullCalendar.tsx +++ b/src/components/database/fullcalendar/FullCalendar.tsx @@ -1,7 +1,7 @@ import dayjs from 'dayjs'; import { memo, useCallback, useEffect, useRef, useState } from 'react'; -import { CalendarEvent } from '@/application/database-yjs'; +import { CalendarEvent, useDatabaseContext } from '@/application/database-yjs'; import DatabaseStickyTopOverlay from '@/components/database/components/sticky-overlay/DatabaseStickyTopOverlay'; import { getPlatform } from '@/utils/platform'; @@ -59,6 +59,7 @@ function Calendar() { // Mobile detection const [isMobile, setIsMobile] = useState(false); + const { onRendered } = useDatabaseContext(); // Check for mobile device on component mount useEffect(() => { @@ -67,6 +68,13 @@ function Calendar() { setIsMobile(isMobile); }, []); + // Notify parent when calendar data is ready (rendered) + useEffect(() => { + if (calendarData) { + onRendered?.(); + } + }, [calendarData, onRendered]); + // Handle calendar data changes from CalendarContent const handleCalendarDataChange = useCallback((data: CalendarData) => { if (data.calendarApi) { diff --git a/src/components/editor/Editable.tsx b/src/components/editor/Editable.tsx index fba23a35c..392de014c 100644 --- a/src/components/editor/Editable.tsx +++ b/src/components/editor/Editable.tsx @@ -1,8 +1,9 @@ +import { autoScrollForElements } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/element'; import { Skeleton } from '@mui/material'; -import React, { lazy, Suspense, useCallback } from 'react'; +import React, { lazy, Suspense, useCallback, useEffect } from 'react'; import { ErrorBoundary } from 'react-error-boundary'; import { BaseRange, Editor, Element as SlateElement, NodeEntry, Range, Text } from 'slate'; -import { Editable, RenderElementProps, useSlate } from 'slate-react'; +import { Editable, ReactEditor, RenderElementProps, useSlate } from 'slate-react'; import { YjsEditor } from '@/application/slate-yjs'; import { CustomEditor } from '@/application/slate-yjs/command'; @@ -17,6 +18,7 @@ import { RemoteSelectionsLayer } from '@/components/editor/components/remote-sel import { useEditorContext } from '@/components/editor/EditorContext'; import { useShortcuts } from '@/components/editor/shortcut.hooks'; import { ElementFallbackRender } from '@/components/error/ElementFallbackRender'; +import { getScrollParent } from '@/components/global-comment/utils'; import { cn } from '@/lib/utils'; import { Element } from './components/element'; @@ -111,6 +113,21 @@ const EditorEditable = () => { setLinkOpen(undefined); }, []); + useEffect(() => { + try { + const editorDom = ReactEditor.toDOMNode(editor, editor); + const scrollContainer = getScrollParent(editorDom); + + if (!scrollContainer) return; + + return autoScrollForElements({ + element: scrollContainer, + }); + } catch (e) { + console.error('Error initializing auto-scroll:', e); + } + }, [editor]); + return ( diff --git a/src/components/editor/components/block-popover/MathEquationPopoverContent.tsx b/src/components/editor/components/block-popover/MathEquationPopoverContent.tsx index b3e545372..d0af345e8 100644 --- a/src/components/editor/components/block-popover/MathEquationPopoverContent.tsx +++ b/src/components/editor/components/block-popover/MathEquationPopoverContent.tsx @@ -57,7 +57,7 @@ function MathEquationPopoverContent({ const inputRef = useRef(null); return ( -
+
{ if (!input) return; diff --git a/src/components/editor/components/block-popover/index.tsx b/src/components/editor/components/block-popover/index.tsx index 8ebafbefa..1c2d15a56 100644 --- a/src/components/editor/components/block-popover/index.tsx +++ b/src/components/editor/components/block-popover/index.tsx @@ -85,7 +85,7 @@ function BlockPopover() { top: panelPosition.bottom, left: panelPosition.left, }, - type === BlockType.ImageBlock || type === BlockType.VideoBlock ? 400 : 560, + 400, type === BlockType.ImageBlock || type === BlockType.VideoBlock ? 366 : 200, defaultOrigins, 16 diff --git a/src/components/editor/components/blocks/database/DatabaseBlock.tsx b/src/components/editor/components/blocks/database/DatabaseBlock.tsx index 0ac95cb07..78050ecb3 100644 --- a/src/components/editor/components/blocks/database/DatabaseBlock.tsx +++ b/src/components/editor/components/blocks/database/DatabaseBlock.tsx @@ -1,6 +1,6 @@ import { forwardRef, memo, useCallback, useEffect, useRef, useState } from 'react'; import { Element } from 'slate'; -import { useReadOnly, useSlateStatic } from 'slate-react'; +import { ReactEditor, useReadOnly, useSlateStatic } from 'slate-react'; import { DatabaseContextState } from '@/application/database-yjs'; import { YjsEditorKey, YSharedRoot } from '@/application/types'; @@ -31,6 +31,79 @@ export const DatabaseBlock = memo( loadViewMeta: context?.loadViewMeta, }); + // Track latest valid scroll position to restore if layout shift resets it + const latestScrollTop = useRef(0); + + useEffect(() => { + let scrollContainer: HTMLElement | null = null; + + try { + const domNode = ReactEditor.toDOMNode(editor, editor); + + scrollContainer = domNode.closest('.appflowy-scroll-container'); + } catch { + // ignore + } + + if (!scrollContainer) { + scrollContainer = document.querySelector('.appflowy-scroll-container'); + } + + if (!scrollContainer) return; + + // Initialize with current scroll position if already scrolled + if (scrollContainer.scrollTop > 0) { + latestScrollTop.current = scrollContainer.scrollTop; + } + + const handleScroll = () => { + if (scrollContainer && scrollContainer.scrollTop > 0) { + latestScrollTop.current = scrollContainer.scrollTop; + } + }; + + scrollContainer.addEventListener('scroll', handleScroll); + return () => { + scrollContainer?.removeEventListener('scroll', handleScroll); + }; + }, [editor]); + + const handleRendered = useCallback(() => { + const restore = () => { + try { + let scrollContainer: HTMLElement | null = null; + + try { + const domNode = ReactEditor.toDOMNode(editor, editor); + + scrollContainer = domNode.closest('.appflowy-scroll-container'); + } catch { + // fallback + } + + if (!scrollContainer) { + scrollContainer = document.querySelector('.appflowy-scroll-container'); + } + + // Only restore if scroll position was reset to 0 (or close to 0) and we had a previous scroll + if (scrollContainer && scrollContainer.scrollTop < 10 && latestScrollTop.current > 50) { + scrollContainer.scrollTop = latestScrollTop.current; + } + } catch { + // Ignore + } + }; + + restore(); + // Try next tick in case of layout shifts + setTimeout(restore, 50); + + // Clear the ref only after attempts to allow future 0-scrolls if valid + setTimeout(() => { + latestScrollTop.current = 0; + }, 1000); + }, [editor]); + const handleNavigateToRow = useCallback( async (rowId: string) => { if (!viewId) return; @@ -53,11 +126,6 @@ export const DatabaseBlock = memo( const hasDb = !!sharedRoot.get(YjsEditorKey.database); setHasDatabase(hasDb); - if (hasDb) { - console.debug('[DatabaseBlock] database found in doc', { viewId }); - } else { - console.warn('[DatabaseBlock] database missing in doc', { viewId }); - } }; setStatus(); @@ -96,6 +164,7 @@ export const DatabaseBlock = memo( iidName={iidName} visibleViewIds={visibleViewIds} onChangeView={onChangeView} + onRendered={handleRendered} // eslint-disable-next-line context={context as DatabaseContextState} /> diff --git a/src/components/editor/components/blocks/database/components/DatabaseContent.tsx b/src/components/editor/components/blocks/database/components/DatabaseContent.tsx index 81f5cb77e..124326f4a 100644 --- a/src/components/editor/components/blocks/database/components/DatabaseContent.tsx +++ b/src/components/editor/components/blocks/database/components/DatabaseContent.tsx @@ -25,6 +25,7 @@ interface DatabaseContentProps { onChangeView: (viewId: string) => void; context: DatabaseContextState; fixedHeight?: number; + onRendered?: () => void; } export const DatabaseContent = ({ @@ -47,6 +48,7 @@ export const DatabaseContent = ({ onChangeView, context, fixedHeight, + onRendered, }: DatabaseContentProps) => { const { t } = useTranslation(); const isPublishVarient = context?.variant === UIVariant.Publish; @@ -79,6 +81,7 @@ export const DatabaseContent = ({ paddingEnd={paddingEnd} isDocumentBlock={true} embeddedHeight={fixedHeight} + onRendered={onRendered} />
); diff --git a/src/components/editor/components/drag-drop/handleBlockDrop.ts b/src/components/editor/components/drag-drop/handleBlockDrop.ts new file mode 100644 index 000000000..31fb58a75 --- /dev/null +++ b/src/components/editor/components/drag-drop/handleBlockDrop.ts @@ -0,0 +1,92 @@ +import { Edge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge'; + +import { YjsEditor } from '@/application/slate-yjs'; +import { + executeOperations, + getBlock, + getBlockIndex, + moveNode, +} from '@/application/slate-yjs/utils/yjs'; +import { CollabOrigin, YjsEditorKey } from '@/application/types'; +import { wouldCreateCircularReference } from '@/components/editor/components/drag-drop/validation'; + +/** + * Handle dropping a block onto another block + */ +export function handleBlockDrop({ + editor, + sourceBlockId, + targetBlockId, + edge, +}: { + editor: YjsEditor; + sourceBlockId: string; + targetBlockId: string; + edge: Edge; +}): boolean { + try { + const { sharedRoot } = editor; + + if (!sharedRoot) { + console.warn('No shared root available'); + return false; + } + + // Get source and target blocks + const sourceBlock = getBlock(sourceBlockId, sharedRoot); + const targetBlock = getBlock(targetBlockId, sharedRoot); + + if (!sourceBlock || !targetBlock) { + console.warn('Source or target block not found'); + return false; + } + + // Prevent circular references + if (wouldCreateCircularReference(sourceBlock, targetBlock, sharedRoot)) { + console.warn('Cannot drop: would create circular reference'); + return false; + } + + // Get the target's parent (source will move to same parent as target) + const targetParentId = targetBlock.get(YjsEditorKey.block_parent); + const targetParent = getBlock(targetParentId, sharedRoot); + + if (!targetParent) { + console.warn('Target parent not found'); + return false; + } + + // Calculate the new index + const targetIndex = getBlockIndex(targetBlockId, sharedRoot); + const sourceParentId = sourceBlock.get(YjsEditorKey.block_parent); + + // Determine new index based on edge + const newIndex = edge === 'top' ? targetIndex : targetIndex + 1; + + console.debug('Moving block:', { + sourceBlockId, + targetBlockId, + edge, + targetIndex, + newIndex, + sameParent: sourceParentId === targetParentId, + }); + + // Execute the move operation in a transaction + executeOperations( + sharedRoot, + [ + () => { + moveNode(sharedRoot, sourceBlock, targetParent, newIndex); + }, + ], + 'handleBlockDrop', + CollabOrigin.LocalManual + ); + + return true; + } catch (error) { + console.error('Error handling block drop:', error); + return false; + } +} diff --git a/src/components/editor/components/drag-drop/useBlockDrag.ts b/src/components/editor/components/drag-drop/useBlockDrag.ts new file mode 100644 index 000000000..8014c4857 --- /dev/null +++ b/src/components/editor/components/drag-drop/useBlockDrag.ts @@ -0,0 +1,196 @@ +import { draggable } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; +import { useEffect, useMemo, useState } from 'react'; +import { ReactEditor, useSlateStatic } from 'slate-react'; + +import { YjsEditor } from '@/application/slate-yjs'; +import { findSlateEntryByBlockId } from '@/application/slate-yjs/utils/editor'; +import { canDragBlock } from '@/components/editor/components/drag-drop/validation'; + +interface UseBlockDragProps { + blockId?: string; + parentId?: string; + dragHandleRef: React.RefObject; + disabled?: boolean; + onDragChange?: (dragging: boolean) => void; +} + +/** + * Generates a custom drag preview element + */ +function generateDragPreview(sourceElement: HTMLElement): HTMLElement { + const container = document.createElement('div'); + const clone = sourceElement.cloneNode(true) as HTMLElement; + const computedStyle = window.getComputedStyle(sourceElement); + const blockType = sourceElement.getAttribute('data-block-type'); + const isImage = blockType === 'image'; + + let targetWidth = sourceElement.offsetWidth; + + if (isImage) { + const img = sourceElement.querySelector('img'); + + if (img && img.offsetWidth > 0) { + targetWidth = img.offsetWidth; + } + } + + // Clean up the clone + clone.classList.remove('block-element--dragging'); + clone.style.margin = '0'; + clone.style.width = '100%'; + clone.style.pointerEvents = 'none'; + + // Style the container to look like a card + Object.assign(container.style, { + width: `${targetWidth}px`, + maxWidth: '600px', + // Allow full height for images (clamped reasonably), clip text blocks short + maxHeight: isImage ? '1000px' : '150px', + backgroundColor: 'var(--bg-body, #ffffff)', + borderRadius: '8px', + boxShadow: 'var(--shadows-sm, 0 4px 20px rgba(0, 0, 0, 0.1))', + overflow: 'hidden', + position: 'absolute', + top: '-1000px', + left: '-1000px', + zIndex: '9999', + pointerEvents: 'none', + border: '1px solid var(--line-divider, rgba(0, 0, 0, 0.1))', + display: 'block', + // Copy key typography styles + fontFamily: computedStyle.fontFamily, + color: computedStyle.color, + lineHeight: computedStyle.lineHeight, + textAlign: computedStyle.textAlign, + direction: computedStyle.direction, + }); + + // Explicitly handle images to ensure they render correctly in the ghost + const originalImages = sourceElement.querySelectorAll('img'); + const clonedImages = container.querySelectorAll('img'); + + originalImages.forEach((orig, index) => { + const clonedImg = clonedImages[index]; + + if (clonedImg) { + // Try to use canvas for better snapshot reliability + try { + if (orig.complete && orig.naturalWidth > 0) { + const canvas = document.createElement('canvas'); + + canvas.width = orig.offsetWidth; + canvas.height = orig.offsetHeight; + const ctx = canvas.getContext('2d'); + + if (ctx) { + ctx.drawImage(orig, 0, 0, canvas.width, canvas.height); + + // Copy styles - use responsive sizing + canvas.style.maxWidth = '100%'; + canvas.style.height = 'auto'; + canvas.style.display = 'block'; + canvas.style.opacity = '1'; + canvas.style.pointerEvents = 'none'; + + clonedImg.parentNode?.replaceChild(canvas, clonedImg); + return; // Successfully replaced with canvas + } + } + } catch (e) { + // Fallback to img tag if canvas fails (e.g. CORS) + } + + // Fallback logic: configure the cloned img tag + clonedImg.src = orig.currentSrc || orig.src; + clonedImg.loading = 'eager'; + clonedImg.style.maxWidth = '100%'; + clonedImg.style.height = 'auto'; + clonedImg.style.opacity = '1'; + clonedImg.style.display = 'block'; + } + }); + + container.appendChild(clone); + document.body.appendChild(container); + + return container; +} + +/** + * Hook to make a block draggable via a drag handle + */ +export function useBlockDrag({ + blockId, + parentId, + dragHandleRef, + disabled = false, + onDragChange, +}: UseBlockDragProps) { + const [isDragging, setIsDragging] = useState(false); + const editor = useSlateStatic() as YjsEditor; + + // Determine if this block can be dragged + const isDraggable = useMemo(() => { + return canDragBlock(editor, blockId || ''); + }, [blockId, editor]); + + useEffect(() => { + const element = dragHandleRef.current; + + if (!element || !blockId || !isDraggable || disabled) { + return; + } + + return draggable({ + element, + getInitialData: () => ({ + type: 'editor-block', + blockId, + parentId, + }), + onGenerateDragPreview: ({ nativeSetDragImage }) => { + try { + const entry = findSlateEntryByBlockId(editor, blockId); + + if (!entry) return; + const [node] = entry; + const blockElement = ReactEditor.toDOMNode(editor, node); + + if (blockElement) { + const preview = generateDragPreview(blockElement); + + nativeSetDragImage?.(preview, 0, 0); + + // Cleanup after the browser takes the snapshot + setTimeout(() => { + document.body.removeChild(preview); + }, 0); + } + } catch (e) { + console.warn('Failed to generate drag preview:', e); + } + }, + onDragStart: () => { + setIsDragging(true); + onDragChange?.(true); + }, + onDrop: () => { + setIsDragging(false); + onDragChange?.(false); + }, + }); + }, [blockId, parentId, dragHandleRef, isDraggable, disabled, onDragChange, editor]); + + // Safety effect: Reset dragging state if blockId becomes invalid or component unmounts while dragging + useEffect(() => { + if ((!blockId || !isDraggable) && isDragging) { + setIsDragging(false); + onDragChange?.(false); + } + }, [blockId, isDraggable, isDragging, onDragChange]); + + return { + isDragging, + isDraggable, + }; +} \ No newline at end of file diff --git a/src/components/editor/components/drag-drop/useBlockDrop.ts b/src/components/editor/components/drag-drop/useBlockDrop.ts new file mode 100644 index 000000000..ee1d8b473 --- /dev/null +++ b/src/components/editor/components/drag-drop/useBlockDrop.ts @@ -0,0 +1,99 @@ +import { dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; +import { attachClosestEdge, Edge, extractClosestEdge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge'; +import { useEffect, useState } from 'react'; + +interface UseBlockDropProps { + blockId?: string; + element: HTMLElement | null; + onDrop: (args: { sourceBlockId: string; targetBlockId: string; edge: Edge }) => void; +} + +interface DragData { + type?: string; + blockId?: string; + parentId?: string; +} + +/** + * Hook to make a block a drop target for other blocks + */ +export function useBlockDrop({ + blockId, + element, + onDrop, +}: UseBlockDropProps) { + const [isDraggingOver, setIsDraggingOver] = useState(false); + const [dropEdge, setDropEdge] = useState(null); + + useEffect(() => { + if (!element || !blockId) { + return; + } + + return dropTargetForElements({ + element: element, + canDrop: ({ source }) => { + const data = source.data as DragData; + + // Only accept editor blocks + if (data.type !== 'editor-block') return false; + // Can't drop a block onto itself + if (data.blockId === blockId) return false; + return true; + }, + getData: ({ input }) => { + return attachClosestEdge( + { blockId }, + { + input, + element: element, + allowedEdges: ['top', 'bottom'], + } + ); + }, + onDragEnter: ({ self, source }) => { + const data = source.data as DragData; + + if (data.blockId === blockId) return; + + const edge = extractClosestEdge(self.data); + + setIsDraggingOver(true); + setDropEdge(edge); + }, + onDrag: ({ self, source }) => { + const data = source.data as DragData; + + if (data.blockId === blockId) return; + + const edge = extractClosestEdge(self.data); + + setDropEdge(edge); + }, + onDragLeave: () => { + setIsDraggingOver(false); + setDropEdge(null); + }, + onDrop: ({ self, source }) => { + const data = source.data as DragData; + const edge = extractClosestEdge(self.data); + + setIsDraggingOver(false); + setDropEdge(null); + + if (data.blockId && edge) { + onDrop({ + sourceBlockId: data.blockId, + targetBlockId: blockId, + edge, + }); + } + }, + }); + }, [blockId, element, onDrop]); + + return { + isDraggingOver, + dropEdge, + }; +} diff --git a/src/components/editor/components/drag-drop/validation.ts b/src/components/editor/components/drag-drop/validation.ts new file mode 100644 index 000000000..e8f45eb9e --- /dev/null +++ b/src/components/editor/components/drag-drop/validation.ts @@ -0,0 +1,97 @@ +import { YjsEditor } from '@/application/slate-yjs'; +import { findSlateEntryByBlockId } from '@/application/slate-yjs/utils/editor'; +import { getBlock, getPageId } from '@/application/slate-yjs/utils/yjs'; +import { BlockType, YBlock, YjsEditorKey, YSharedRoot } from '@/application/types'; + +/** + * Check if a block can be dragged + */ +export function canDragBlock(editor: YjsEditor, blockId: string): boolean { + if (!blockId || !editor.sharedRoot) return false; + + try { + const pageId = getPageId(editor.sharedRoot); + + // Can't drag the root page block + if (blockId === pageId) return false; + + // Find the block in Slate + const entry = findSlateEntryByBlockId(editor, blockId); + + if (!entry) return false; + + const [node] = entry; + const blockType = node.type as BlockType; + + // Can't drag table cells or table rows + if ([ + BlockType.TableCell, + BlockType.SimpleTableRowBlock, + BlockType.SimpleTableCellBlock, + ].includes(blockType)) { + return false; + } + + return true; + } catch (error) { + console.warn('Error checking if block is draggable:', error); + return false; + } +} + +/** + * Check if dropping sourceBlock onto targetBlock would create a circular reference + */ +export function wouldCreateCircularReference( + sourceBlock: YBlock, + targetBlock: YBlock, + sharedRoot: YSharedRoot +): boolean { + const sourceBlockId = sourceBlock.get(YjsEditorKey.block_id); + + // Walk up the tree from target to see if we hit source + let currentBlock = targetBlock; + + while (currentBlock) { + const currentId = currentBlock.get(YjsEditorKey.block_id); + + if (currentId === sourceBlockId) { + return true; + } + + const parentId = currentBlock.get(YjsEditorKey.block_parent); + + if (!parentId) break; + + try { + currentBlock = getBlock(parentId, sharedRoot); + } catch { + break; + } + } + + return false; +} + +/** + * Validate if a drop operation is allowed + */ +export function canDropBlock({ + editor, + sourceBlockId, + targetBlockId, +}: { + editor: YjsEditor; + sourceBlockId: string; + targetBlockId: string; +}): boolean { + if (sourceBlockId === targetBlockId) return false; + if (!editor.sharedRoot) return false; + + const sourceBlock = getBlock(sourceBlockId, editor.sharedRoot); + const targetBlock = getBlock(targetBlockId, editor.sharedRoot); + + if (!sourceBlock || !targetBlock) return false; + + return !wouldCreateCircularReference(sourceBlock, targetBlock, editor.sharedRoot); +} diff --git a/src/components/editor/components/element/Element.tsx b/src/components/editor/components/element/Element.tsx index 5e8568d18..6c1ab5358 100644 --- a/src/components/editor/components/element/Element.tsx +++ b/src/components/editor/components/element/Element.tsx @@ -1,7 +1,10 @@ +import { Edge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge'; +import { DropIndicator } from '@atlaskit/pragmatic-drag-and-drop-react-drop-indicator/box'; import React, { FC, useCallback, useEffect, useLayoutEffect, useMemo } from 'react'; import { ErrorBoundary, FallbackProps } from 'react-error-boundary'; import { ReactEditor, RenderElementProps, useSelected, useSlateStatic } from 'slate-react'; +import { YjsEditor } from '@/application/slate-yjs'; import { CONTAINER_BLOCK_TYPES, SOFT_BREAK_TYPES } from '@/application/slate-yjs/command/const'; import { BlockData, BlockType, ColumnNodeData, YjsEditorKey } from '@/application/types'; import { BulletedList } from '@/components/editor/components/blocks/bulleted-list'; @@ -27,6 +30,8 @@ import SimpleTableRow from '@/components/editor/components/blocks/simple-table/S import { TableBlock, TableCellBlock } from '@/components/editor/components/blocks/table'; import { Text } from '@/components/editor/components/blocks/text'; import { VideoBlock } from '@/components/editor/components/blocks/video'; +import { handleBlockDrop } from '@/components/editor/components/drag-drop/handleBlockDrop'; +import { useBlockDrop } from '@/components/editor/components/drag-drop/useBlockDrop'; import { BlockNotFound } from '@/components/editor/components/element/BlockNotFound'; import { EditorElementProps, TextNode } from '@/components/editor/editor.type'; import { useEditorContext } from '@/components/editor/EditorContext'; @@ -73,6 +78,16 @@ export const Element = ({ const editor = useSlateStatic(); const highlightTimeoutRef = React.useRef(); + const [blockElement, setBlockElement] = React.useState(null); + const allowBlockDrop = useMemo(() => { + if (type === YjsEditorKey.text) { + return false; + } + + const blockType = node.type as BlockType; + + return ![BlockType.SimpleTableRowBlock, BlockType.SimpleTableCellBlock].includes(blockType); + }, [node.type, type]); const scrollAndHighlight = useCallback(async (element: HTMLElement) => { element.scrollIntoView({ block: 'start' }); @@ -104,6 +119,41 @@ export const Element = ({ } }; }, []); + + useLayoutEffect(() => { + if (!allowBlockDrop) { + setBlockElement(null); + return; + } + + try { + const domNode = ReactEditor.toDOMNode(editor, node); + + setBlockElement((current) => (current === domNode ? current : domNode)); + } catch { + setBlockElement(null); + } + }, [allowBlockDrop, editor, node]); + + const onDropBlock = useCallback( + ({ sourceBlockId, targetBlockId, edge }: { sourceBlockId: string; targetBlockId: string; edge: Edge }) => { + if (!allowBlockDrop) return; + + handleBlockDrop({ + editor: editor as YjsEditor, + sourceBlockId, + targetBlockId, + edge, + }); + }, + [allowBlockDrop, editor] + ); + + const { isDraggingOver, dropEdge } = useBlockDrop({ + blockId: allowBlockDrop ? (blockId ?? undefined) : undefined, + element: allowBlockDrop ? blockElement : null, + onDrop: onDropBlock, + }); const Component = useMemo(() => { switch (type) { case BlockType.HeadingBlock: @@ -254,9 +304,12 @@ export const Element = ({
+ {allowBlockDrop && isDraggingOver && dropEdge === 'top' && ( + + )} {children} + {allowBlockDrop && isDraggingOver && dropEdge === 'bottom' && ( + + )}
); diff --git a/src/components/editor/components/panels/PanelsContext.tsx b/src/components/editor/components/panels/PanelsContext.tsx index ec83da057..a781ea14e 100644 --- a/src/components/editor/components/panels/PanelsContext.tsx +++ b/src/components/editor/components/panels/PanelsContext.tsx @@ -199,9 +199,10 @@ export const PanelProvider = ({ children, editor }: { children: React.ReactNode; useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - if (!openRef.current) return; const { key } = e; + if (!openRef.current) return; + switch (key) { case 'Escape': e.stopPropagation(); @@ -209,6 +210,12 @@ export const PanelProvider = ({ children, editor }: { children: React.ReactNode; break; case 'ArrowLeft': case 'ArrowRight': { + // Allow Shift+Arrow for text selection even when panel is open + if (e.shiftKey) { + // Let the browser handle Shift+Arrow for text selection + return; + } + e.preventDefault(); break; } @@ -259,4 +266,4 @@ export const PanelProvider = ({ children, editor }: { children: React.ReactNode; {children} ); -}; +}; \ No newline at end of file diff --git a/src/components/editor/components/panels/mention-panel/MentionPanel.tsx b/src/components/editor/components/panels/mention-panel/MentionPanel.tsx index 656cae41c..4550c6198 100644 --- a/src/components/editor/components/panels/mention-panel/MentionPanel.tsx +++ b/src/components/editor/components/panels/mention-panel/MentionPanel.tsx @@ -16,10 +16,10 @@ import { ReactComponent as MoreIcon } from '@/assets/icons/more.svg'; import { ReactComponent as AddIcon } from '@/assets/icons/plus.svg'; import { flattenViews } from '@/components/_shared/outline/utils'; import { calculateOptimalOrigins, Popover } from '@/components/_shared/popover'; +import PageIcon from '@/components/_shared/view-icon/PageIcon'; import { usePanelContext } from '@/components/editor/components/panels/Panels.hooks'; import { PanelType } from '@/components/editor/components/panels/PanelsContext'; import { useEditorContext } from '@/components/editor/EditorContext'; -import PageIcon from '@/components/_shared/view-icon/PageIcon'; enum MentionTag { Reminder = 'reminder', diff --git a/src/components/editor/components/panels/slash-panel/SlashPanel.tsx b/src/components/editor/components/panels/slash-panel/SlashPanel.tsx index 8f89faeaa..50f997b94 100644 --- a/src/components/editor/components/panels/slash-panel/SlashPanel.tsx +++ b/src/components/editor/components/panels/slash-panel/SlashPanel.tsx @@ -2,6 +2,7 @@ import { Button } from '@mui/material'; import { PopoverOrigin } from '@mui/material/Popover/Popover'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { Transforms } from 'slate'; import { ReactEditor, useSlateStatic } from 'slate-react'; import { YjsEditor } from '@/application/slate-yjs'; @@ -213,6 +214,7 @@ export function SlashPanel({ const [transformOrigin, setTransformOrigin] = React.useState(undefined); const selectedOptionRef = React.useRef(null); const { openPopover } = usePopoverContext(); + const open = useMemo(() => { return isPanelOpen(PanelType.Slash); }, [isPanelOpen]); @@ -296,13 +298,21 @@ export function SlashPanel({ } if (newBlockId && isEmbedBlockTypes(type)) { - const entry = findSlateEntryByBlockId(editor, newBlockId); + // Skip selection for database blocks (Grid, Board, Calendar) as they open in a modal + // and don't need cursor positioning. Explicitly deselect to prevent Slate from scrolling. + const isDatabaseBlock = [BlockType.GridBlock, BlockType.BoardBlock, BlockType.CalendarBlock].includes(type); + + if (isDatabaseBlock) { + Transforms.deselect(editor); + } else { + const entry = findSlateEntryByBlockId(editor, newBlockId); - if (!entry) return; + if (!entry) return; - const [, path] = entry; + const [, path] = entry; - editor.select(editor.start(path)); + editor.select(editor.start(path)); + } } if ([BlockType.FileBlock, BlockType.ImageBlock, BlockType.EquationBlock, BlockType.VideoBlock].includes(type)) { @@ -711,6 +721,23 @@ export function SlashPanel({ keywords: ['grid', 'table', 'database'], onClick: async () => { if (!viewId || !addPage || !openPageModal) return; + + let scrollContainer: Element | null = null; + + try { + const domNode = ReactEditor.toDOMNode(editor, editor); + + scrollContainer = domNode.closest('.appflowy-scroll-container'); + } catch (e) { + // Ignore + } + + if (!scrollContainer) { + scrollContainer = document.querySelector('.appflowy-scroll-container'); + } + + const savedScrollTop = scrollContainer?.scrollTop; + try { const newViewId = await addPage(viewId, { layout: ViewLayout.Grid, @@ -723,6 +750,18 @@ export function SlashPanel({ } as DatabaseNodeData); openPageModal(newViewId); + + if (savedScrollTop !== undefined) { + const restoreScroll = () => { + const currentContainer = document.querySelector('.appflowy-scroll-container'); + + if (currentContainer) { + currentContainer.scrollTop = savedScrollTop; + } + }; + + setTimeout(restoreScroll, 50); + } // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (e: any) { notify.error(e.message); @@ -745,6 +784,23 @@ export function SlashPanel({ keywords: ['board', 'kanban', 'database'], onClick: async () => { if (!viewId || !addPage || !openPageModal) return; + + let scrollContainer: Element | null = null; + + try { + const domNode = ReactEditor.toDOMNode(editor, editor); + + scrollContainer = domNode.closest('.appflowy-scroll-container'); + } catch (e) { + // Ignore + } + + if (!scrollContainer) { + scrollContainer = document.querySelector('.appflowy-scroll-container'); + } + + const savedScrollTop = scrollContainer?.scrollTop; + try { const newViewId = await addPage(viewId, { layout: ViewLayout.Board, @@ -757,6 +813,18 @@ export function SlashPanel({ } as DatabaseNodeData); openPageModal(newViewId); + + if (savedScrollTop !== undefined) { + const restoreScroll = () => { + const currentContainer = document.querySelector('.appflowy-scroll-container'); + + if (currentContainer) { + currentContainer.scrollTop = savedScrollTop; + } + }; + + setTimeout(restoreScroll, 50); + } // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (e: any) { notify.error(e.message); @@ -779,6 +847,23 @@ export function SlashPanel({ keywords: ['calendar', 'date', 'database'], onClick: async () => { if (!viewId || !addPage || !openPageModal) return; + + let scrollContainer: Element | null = null; + + try { + const domNode = ReactEditor.toDOMNode(editor, editor); + + scrollContainer = domNode.closest('.appflowy-scroll-container'); + } catch (e) { + // Ignore + } + + if (!scrollContainer) { + scrollContainer = document.querySelector('.appflowy-scroll-container'); + } + + const savedScrollTop = scrollContainer?.scrollTop; + try { const newViewId = await addPage(viewId, { layout: ViewLayout.Calendar, @@ -791,6 +876,18 @@ export function SlashPanel({ } as DatabaseNodeData); openPageModal(newViewId); + + if (savedScrollTop !== undefined) { + const restoreScroll = () => { + const currentContainer = document.querySelector('.appflowy-scroll-container'); + + if (currentContainer) { + currentContainer.scrollTop = savedScrollTop; + } + }; + + setTimeout(restoreScroll, 50); + } // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (e: any) { notify.error(e.message); @@ -938,6 +1035,7 @@ export function SlashPanel({ setEmojiPosition, searchText, handleOpenLinkedDatabasePicker, + editor, ]); const resultLength = options.length; @@ -947,10 +1045,23 @@ export function SlashPanel({ if (!selectedOption) return; const el = optionsRef.current?.querySelector(`[data-option-key="${selectedOption}"]`) as HTMLButtonElement | null; - el?.scrollIntoView({ - behavior: 'smooth', - block: 'nearest', - }); + // Scroll the option into view within the menu only, without affecting parent scroll containers + if (el && optionsRef.current) { + const menu = optionsRef.current; + const elOffsetTop = el.offsetTop; + const elHeight = el.offsetHeight; + const menuScrollTop = menu.scrollTop; + const menuHeight = menu.clientHeight; + + // Scroll the menu container (not the entire page) to show the selected option + if (elOffsetTop < menuScrollTop) { + // Element is above visible area + menu.scrollTop = elOffsetTop; + } else if (elOffsetTop + elHeight > menuScrollTop + menuHeight) { + // Element is below visible area + menu.scrollTop = elOffsetTop + elHeight - menuHeight; + } + } }, [selectedOption]); useEffect(() => { diff --git a/src/components/editor/components/toolbar/block-controls/ControlActions.tsx b/src/components/editor/components/toolbar/block-controls/ControlActions.tsx index e479a8467..d284799da 100644 --- a/src/components/editor/components/toolbar/block-controls/ControlActions.tsx +++ b/src/components/editor/components/toolbar/block-controls/ControlActions.tsx @@ -1,5 +1,5 @@ import { IconButton, Tooltip } from '@mui/material'; -import React, { useCallback, useState } from 'react'; +import React, { useCallback, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Transforms } from 'slate'; import { ReactEditor, useSlateStatic } from 'slate-react'; @@ -11,6 +11,7 @@ import { filterValidNodes, findSlateEntryByBlockId, getSelectedPaths } from '@/a import { BlockType } from '@/application/types'; import { ReactComponent as DragSvg } from '@/assets/icons/drag.svg'; import { ReactComponent as AddSvg } from '@/assets/icons/plus.svg'; +import { useBlockDrag } from '@/components/editor/components/drag-drop/useBlockDrag'; import { usePanelContext } from '@/components/editor/components/panels/Panels.hooks'; import { PanelType } from '@/components/editor/components/panels/PanelsContext'; import ControlsMenu from '@/components/editor/components/toolbar/block-controls/ControlsMenu'; @@ -18,16 +19,18 @@ import { getRangeRect } from '@/components/editor/components/toolbar/selection-t import { useEditorContext } from '@/components/editor/EditorContext'; import { isMac } from '@/utils/hotkeys'; - - - -function ControlActions({ setOpenMenu, blockId }: { +type ControlActionsProps = { blockId: string | null; + parentId?: string | null; setOpenMenu?: (open: boolean) => void; -}) { + onDraggingChange?: (dragging: boolean) => void; +}; + +function ControlActions({ setOpenMenu, blockId, parentId, onDraggingChange }: ControlActionsProps) { const { setSelectedBlockIds } = useEditorContext(); const [menuAnchorEl, setMenuAnchorEl] = useState(null); const openMenu = Boolean(menuAnchorEl); + const dragHandleRef = useRef(null); const editor = useSlateStatic() as YjsEditor; const { t } = useTranslation(); @@ -114,10 +117,18 @@ function ControlActions({ setOpenMenu, blockId }: { onAdded(); }, [editor, blockId, onAdded]); + const { isDragging } = useBlockDrag({ + blockId: blockId ?? undefined, + parentId: parentId ?? undefined, + dragHandleRef, + disabled: openMenu, + onDragChange: onDraggingChange, + }); + return (
+ title={
{t('blockActions.addBelowTooltip')}
{`${isMac() ? t('blockActions.addAboveMacCmd') : t('blockActions.addAboveCmd')} ${t('blockActions.addAboveTooltip')}`}
} @@ -132,17 +143,23 @@ function ControlActions({ setOpenMenu, blockId }: {
+ title={
+
{t('blockActions.dragTooltip')}
{t('blockActions.openMenuTooltip')}
} disableInteractive={true} > { + event.stopPropagation(); + }} > - +
{blockId && openMenu && (null); const [hoveredBlockId, setHoveredBlockId] = useState(null); + const [hoveredBlockParentId, setHoveredBlockParentId] = useState(null); const [cssProperty, setCssProperty] = useState(''); const recalculatePosition = useCallback( @@ -37,9 +38,44 @@ export function useHoverControls({ disabled }: { disabled: boolean }) { el.style.opacity = '0'; el.style.pointerEvents = 'none'; setHoveredBlockId(null); + setHoveredBlockParentId(null); setCssProperty(''); }, [ref]); + const updateParentId = useCallback((blockId: string | null) => { + if (!blockId) { + setHoveredBlockParentId(null); + return; + } + + try { + const entry = findSlateEntryByBlockId(editor, blockId); + + if (!entry) { + setHoveredBlockParentId(null); + return; + } + + const [, path] = entry; + + if (!path || path.length === 0) { + setHoveredBlockParentId(null); + return; + } + + const parentPath = Path.parent(path); + const parentEntry = Editor.node(editor, parentPath); + + if (Element.isElement(parentEntry[0]) && parentEntry[0].blockId) { + setHoveredBlockParentId(parentEntry[0].blockId); + } else { + setHoveredBlockParentId(null); + } + } catch { + setHoveredBlockParentId(null); + } + }, [editor]); + useEffect(() => { const handleMouseMove = (e: MouseEvent) => { if (disabled) return; @@ -107,6 +143,7 @@ export function useHoverControls({ disabled }: { disabled: boolean }) { setCssProperty(getBlockCssProperty(node)); setHoveredBlockId(node.blockId as string); + updateParentId(node.blockId as string); } }; @@ -116,6 +153,19 @@ export function useHoverControls({ disabled }: { disabled: boolean }) { dom.addEventListener('mousemove', handleMouseMove); dom.parentElement?.addEventListener('mouseleave', close); getScrollParent(dom)?.addEventListener('scroll', close); + + // Check if the hovered block still exists (e.g. after a drag-and-drop operation where the ID changed) + if (hoveredBlockId) { + try { + const entry = findSlateEntryByBlockId(editor, hoveredBlockId); + + if (!entry) { + close(); + } + } catch { + close(); + } + } } return () => { @@ -123,7 +173,7 @@ export function useHoverControls({ disabled }: { disabled: boolean }) { dom.parentElement?.removeEventListener('mouseleave', close); getScrollParent(dom)?.removeEventListener('scroll', close); }; - }, [close, editor, ref, recalculatePosition, disabled]); + }, [close, editor, ref, recalculatePosition, disabled, updateParentId, hoveredBlockId]); useEffect(() => { let observer: MutationObserver | null = null; @@ -140,7 +190,11 @@ export function useHoverControls({ disabled }: { disabled: boolean }) { const dom = ReactEditor.toDOMNode(editor, node); if (dom.parentElement) { - observer = new MutationObserver(close); + observer = new MutationObserver(() => { + if (!disabled) { + close(); + } + }); observer.observe(dom.parentElement, { childList: true, @@ -154,10 +208,11 @@ export function useHoverControls({ disabled }: { disabled: boolean }) { return () => { observer?.disconnect(); }; - }, [close, editor, hoveredBlockId]); + }, [close, editor, hoveredBlockId, disabled]); return { hoveredBlockId, + hoveredBlockParentId, ref, cssProperty, }; diff --git a/src/components/editor/components/toolbar/block-controls/HoverControls.tsx b/src/components/editor/components/toolbar/block-controls/HoverControls.tsx index d838d4927..018d6d383 100644 --- a/src/components/editor/components/toolbar/block-controls/HoverControls.tsx +++ b/src/components/editor/components/toolbar/block-controls/HoverControls.tsx @@ -1,15 +1,45 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; +import { ReactEditor, useSlateStatic } from 'slate-react'; +import { YjsEditor } from '@/application/slate-yjs'; +import { findSlateEntryByBlockId } from '@/application/slate-yjs/utils/editor'; import ControlActions from '@/components/editor/components/toolbar/block-controls/ControlActions'; import { useHoverControls } from '@/components/editor/components/toolbar/block-controls/HoverControls.hooks'; export function HoverControls () { const [openMenu, setOpenMenu] = useState(false); + const [isDragging, setIsDragging] = useState(false); + const editor = useSlateStatic() as YjsEditor; - const { ref, cssProperty, hoveredBlockId } = useHoverControls({ - disabled: openMenu, + const { ref, cssProperty, hoveredBlockId, hoveredBlockParentId } = useHoverControls({ + disabled: openMenu || isDragging, }); + useEffect(() => { + if (!hoveredBlockId) return; + + try { + const entry = findSlateEntryByBlockId(editor, hoveredBlockId); + + if (!entry) return; + + const [node] = entry; + const blockElement = ReactEditor.toDOMNode(editor, node); + + if (isDragging) { + blockElement.classList.add('block-element--dragging'); + } else { + blockElement.classList.remove('block-element--dragging'); + } + + return () => { + blockElement.classList.remove('block-element--dragging'); + }; + } catch { + // ignore + } + }, [editor, hoveredBlockId, isDragging]); + return ( <>
{ e.preventDefault(); }} - className={`absolute hover-controls w-[64px] px-1 z-10 opacity-0 flex items-center justify-end ${cssProperty}`} + className={`absolute hover-controls w-[64px] px-1 z-10 opacity-0 flex items-center justify-end ${cssProperty} ${isDragging ? 'pointer-events-none opacity-0' : ''}`} > {/* Ensure the toolbar in middle */}
$
@@ -34,4 +66,4 @@ export function HoverControls () { ); } -export default HoverControls; \ No newline at end of file +export default HoverControls; diff --git a/src/components/editor/editor.scss b/src/components/editor/editor.scss index 0580db621..2fdab9789 100644 --- a/src/components/editor/editor.scss +++ b/src/components/editor/editor.scss @@ -18,6 +18,15 @@ } +.block-element--dragging { + @apply opacity-50; + cursor: grabbing; +} + +.block-element.block-drop-target { + @apply bg-fill-list-hover; +} + .block-element[data-block-type="table/cell"] { .block-element .text-placeholder { @apply hidden; @@ -538,4 +547,4 @@ span[data-slate-placeholder="true"]:not(.inline-block-content) { } -} \ No newline at end of file +} diff --git a/src/utils/download.ts b/src/utils/download.ts index b78d8e989..98d0b2b85 100644 --- a/src/utils/download.ts +++ b/src/utils/download.ts @@ -1,4 +1,5 @@ import download from 'downloadjs'; + import { getTokenParsed } from '@/application/session/token'; import { isAppFlowyFileStorageUrl } from '@/utils/file-storage-url';