diff --git a/.storybook/decorators.tsx b/.storybook/decorators.tsx index 26d17c9c7..458aa8cbb 100644 --- a/.storybook/decorators.tsx +++ b/.storybook/decorators.tsx @@ -5,7 +5,7 @@ * Import and use these decorators in your stories. */ -import React, { useEffect, useRef } from 'react'; +import React, { useEffect } from 'react'; import { AppContext } from '@/components/app/app.hooks'; import { AFConfigContext } from '@/components/main/app.hooks'; @@ -16,80 +16,12 @@ import { mockAFConfigValue, mockAFConfigValueMinimal, mockAppContextValue } from */ declare global { interface Window { - __APP_CONFIG__?: { - APPFLOWY_BASE_URL?: string; - APPFLOWY_GOTRUE_BASE_URL?: string; - APPFLOWY_WS_BASE_URL?: string; - }; + __STORYBOOK_MOCK_HOSTNAME__?: string; } } -type CleanupFn = () => void; - -const normalizeHostnameToBaseUrl = (hostname: string): string => { - const trimmed = hostname.trim(); - - if (!trimmed) { - return ''; - } - - if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) { - return trimmed; - } - - return `https://${trimmed}`; -}; - -export const mockHostname = (hostname: string): CleanupFn => { - if (typeof window === 'undefined') { - return () => {}; - } - - const previousConfig = window.__APP_CONFIG__ ? { ...window.__APP_CONFIG__ } : undefined; - const formattedHostname = hostname?.trim(); - - if (!formattedHostname) { - delete window.__APP_CONFIG__; - return () => { - if (previousConfig) { - window.__APP_CONFIG__ = previousConfig; - } - }; - } - - const baseUrl = normalizeHostnameToBaseUrl(formattedHostname); - - window.__APP_CONFIG__ = { - ...(window.__APP_CONFIG__ ?? {}), - APPFLOWY_BASE_URL: baseUrl, - }; - - return () => { - if (previousConfig) { - window.__APP_CONFIG__ = previousConfig; - } else { - delete window.__APP_CONFIG__; - } - }; -}; - -export const useHostnameMock = (hostname: string) => { - const cleanupRef = useRef(null); - const appliedHostnameRef = useRef(); - - if (appliedHostnameRef.current !== hostname) { - cleanupRef.current?.(); - cleanupRef.current = mockHostname(hostname); - appliedHostnameRef.current = hostname; - } - - useEffect(() => { - return () => { - cleanupRef.current?.(); - cleanupRef.current = null; - appliedHostnameRef.current = undefined; - }; - }, []); +export const mockHostname = (hostname: string) => { + window.__STORYBOOK_MOCK_HOSTNAME__ = hostname; }; /** @@ -157,7 +89,17 @@ export const withHostnameMocking = () => { return (Story: React.ComponentType, context: { args: { hostname?: string } }) => { const hostname = context.args.hostname || 'beta.appflowy.cloud'; - useHostnameMock(hostname); + // Set mock hostname synchronously before render + mockHostname(hostname); + + useEffect(() => { + // Update if hostname changes + mockHostname(hostname); + // Cleanup + return () => { + delete (window as any).__STORYBOOK_MOCK_HOSTNAME__; + }; + }, [hostname]); return ; }; @@ -181,7 +123,15 @@ export const withHostnameAndContexts = (options?: { return (Story: React.ComponentType, context: { args: { hostname?: string } }) => { const hostname = context.args.hostname || 'beta.appflowy.cloud'; - useHostnameMock(hostname); + // Set mock hostname synchronously before render + mockHostname(hostname); + + useEffect(() => { + mockHostname(hostname); + return () => { + delete (window as any).__STORYBOOK_MOCK_HOSTNAME__; + }; + }, [hostname]); const afConfigValue = minimalAFConfig ? mockAFConfigValueMinimal : mockAFConfigValue; diff --git a/cypress/e2e/app/upgrade-plan.cy.ts b/cypress/e2e/app/upgrade-plan.cy.ts deleted file mode 100644 index cff960b5b..000000000 --- a/cypress/e2e/app/upgrade-plan.cy.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { AuthTestUtils } from '../../support/auth-utils'; -import { SidebarSelectors, WorkspaceSelectors } from '../../support/selectors'; -import { generateRandomEmail } from '../../support/test-config'; -import enTranslations from '../../../src/@types/translations/en.json'; - -const UPGRADE_MENU_LABEL = enTranslations.subscribe?.changePlan ?? 'Upgrade to Pro Plan'; - -describe('Workspace Upgrade Entry', () => { - let testEmail: string; - - beforeEach(() => { - testEmail = generateRandomEmail(); - - cy.on('uncaught:exception', (err: Error) => { - if ( - err.message.includes('No workspace or service found') || - err.message.includes('View not found') || - err.message.includes('WebSocket') || - err.message.includes('connection') || - err.message.includes('Failed to load models') || - err.message.includes('Minified React error') || - err.message.includes('ResizeObserver loop') || - err.message.includes('Non-Error promise rejection') - ) { - return false; - } - return true; - }); - }); - - it('shows Upgrade to Pro Plan for workspace owners', function () { - cy.visit('/login', { failOnStatusCode: false }); - cy.wait(2000); - - const authUtils = new AuthTestUtils(); - authUtils.signInWithTestUrl(testEmail).then(() => { - cy.url().should('include', '/app'); - - SidebarSelectors.pageHeader().should('be.visible', { timeout: 30000 }); - - WorkspaceSelectors.dropdownTrigger().should('be.visible', { timeout: 30000 }).click(); - - WorkspaceSelectors.dropdownContent() - .should('be.visible', { timeout: 10000 }) - .within(() => { - // Prove the workspace menu actually opened by checking additional menu items - cy.contains('Create workspace').should('be.visible'); - cy.contains(UPGRADE_MENU_LABEL).should('be.visible'); - }); - - cy.screenshot('workspace-upgrade-menu'); - }); - }); -}); 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/drag_drop_blocks.cy.ts b/cypress/e2e/editor/drag_drop_blocks.cy.ts new file mode 100644 index 000000000..35fcb71f2 --- /dev/null +++ b/cypress/e2e/editor/drag_drop_blocks.cy.ts @@ -0,0 +1,259 @@ +import { AuthTestUtils } from '../../support/auth-utils'; +import { 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 get, else contains + return sourceText.startsWith('[') ? cy.get(sourceText) : cy.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).realHover({ position: 'center' }); + + // 2. Get the drag handle + cy.get('[data-testid="drag-block"]').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 + cy.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(100); // 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('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'); + } + + // Verify final order (Should be 1, 2, 3, 4, 5) + items.forEach((item, index) => { + cy.get('[data-block-type="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) => { + cy.get('[data-block-type="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 + cy.get('[data-block-type="heading"]').should('exist'); + cy.get('[data-block-type="paragraph"]').should('exist'); + + // Drag Header below Paragraph + dragBlock('Header Block', 'Paragraph Block', 'bottom'); + + // Verify Order: Paragraph, Header + 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'); + }); + + // 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 + cy.get('[data-block-type="callout"]').should('exist'); + + // Initial State: Top Text, Callout, Bottom Text + // Action: Drag Callout below Bottom Text + dragBlock('[data-block-type="callout"]', 'Bottom Text', 'bottom'); + + // Verify: Top Text, Bottom Text, Callout + cy.get('[data-block-type]').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'); + }); + }); + }); + +}); \ No newline at end of file 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/embeded/image/copy_image.cy.ts b/cypress/e2e/embeded/image/copy_image.cy.ts deleted file mode 100644 index 4f90532bc..000000000 --- a/cypress/e2e/embeded/image/copy_image.cy.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { v4 as uuidv4 } from 'uuid'; -import { AuthTestUtils } from '../../../support/auth-utils'; -import { EditorSelectors, waitForReactUpdate, SlashCommandSelectors, AddPageSelectors } from '../../../support/selectors'; - -describe('Copy Image Test', () => { - const authUtils = new AuthTestUtils(); - const testEmail = `${uuidv4()}@appflowy.io`; - - beforeEach(() => { - cy.on('uncaught:exception', () => false); - - // Mock the image fetch - cy.intercept('GET', '**/logo.png', { - statusCode: 200, - fixture: 'appflowy.png', - headers: { - 'content-type': 'image/png', - }, - }).as('getImage'); - - // We need to mock the clipboard write - cy.window().then((win) => { - // Check if clipboard exists - if (win.navigator.clipboard) { - cy.stub(win.navigator.clipboard, 'write').as('clipboardWrite'); - } else { - // Mock clipboard if it doesn't exist or is not writable directly - // In some browsers, we might need to redefine the property - const clipboardMock = { - write: cy.stub().as('clipboardWrite') - }; - try { - // @ts-ignore - win.navigator.clipboard = clipboardMock; - } catch (e) { - Object.defineProperty(win.navigator, 'clipboard', { - value: clipboardMock, - configurable: true, - writable: true - }); - } - } - }); - - cy.visit('/login', { failOnStatusCode: false }); - authUtils.signInWithTestUrl(testEmail).then(() => { - cy.url({ timeout: 30000 }).should('include', '/app'); - waitForReactUpdate(1000); - }); - }); - - it('should copy image to clipboard when clicking copy button', () => { - // Create a new page - AddPageSelectors.inlineAddButton().first().click(); - waitForReactUpdate(500); - cy.get('[role="menuitem"]').first().click(); // Create Doc - waitForReactUpdate(1000); - - // Focus editor - EditorSelectors.firstEditor().should('exist').click({ force: true }); - waitForReactUpdate(1000); - - // Ensure focus - EditorSelectors.firstEditor().focus(); - waitForReactUpdate(500); - - // Type '/' to open slash menu - EditorSelectors.firstEditor().type('/', { force: true }); - waitForReactUpdate(1000); - - // Check if slash panel exists - cy.get('[data-testid="slash-panel"]').should('exist').should('be.visible'); - - // Type 'image' to filter - EditorSelectors.firstEditor().type('image', { force: true }); - waitForReactUpdate(1000); - - // Click Image item - cy.get('[data-testid^="slash-menu-"]').contains(/^Image$/).click({ force: true }); - waitForReactUpdate(1000); - - // Upload image directly - cy.get('input[type="file"]').attachFile('appflowy.png'); - waitForReactUpdate(2000); - - waitForReactUpdate(2000); - - // The image should now be rendered. - // We need to hover or click it to see the toolbar. - // The toolbar is only visible when the block is selected/focused or hovered. - // ImageToolbar.tsx uses useSlateStatic, suggesting it's part of the slate render. - - // Find the image block. - cy.get('[data-block-type="image"]').first().should('exist').trigger('mouseover', { force: true }).click({ force: true }); - waitForReactUpdate(1000); - - // Click the copy button - cy.get('[data-testid="copy-image-button"]').should('exist').click({ force: true }); - - // Verify clipboard write - cy.get('@clipboardWrite').should('have.been.called'); - cy.get('@clipboardWrite').should((stub: any) => { - const clipboardItem = stub.args[0][0][0]; - expect(clipboardItem).to.be.instanceOf(ClipboardItem); - expect(clipboardItem.types).to.include('image/png'); - }); - }); -}); diff --git a/cypress/e2e/page/publish-page.cy.ts b/cypress/e2e/page/publish-page.cy.ts index 6102d8b8c..972255e18 100644 --- a/cypress/e2e/page/publish-page.cy.ts +++ b/cypress/e2e/page/publish-page.cy.ts @@ -693,45 +693,6 @@ describe('Publish Page Test', () => { }); }); - it('opens publish manage modal from namespace caret and closes share popover first', () => { - cy.on('uncaught:exception', (err: Error) => { - if (err.message.includes('No workspace or service found') || - err.message.includes('createThemeNoVars_default is not a function') || - err.message.includes('View not found')) { - return false; - } - return true; - }); - - cy.visit('/login', { failOnStatusCode: false }); - cy.wait(1000); - const authUtils = new AuthTestUtils(); - authUtils.signInWithTestUrl(testEmail).then(() => { - cy.url().should('include', '/app'); - SidebarSelectors.pageHeader().should('be.visible', { timeout: 30000 }); - PageSelectors.names().should('exist', { timeout: 30000 }); - cy.wait(2000); - - TestTool.openSharePopover(); - cy.contains('Publish').should('exist').click({ force: true }); - cy.wait(1000); - - ShareSelectors.publishConfirmButton().should('be.visible').click({ force: true }); - cy.wait(5000); - ShareSelectors.publishNamespace().should('be.visible', { timeout: 10000 }); - - ShareSelectors.sharePopover().should('exist'); - ShareSelectors.openPublishSettingsButton().should('be.visible').click({ force: true }); - - ShareSelectors.sharePopover().should('not.exist'); - ShareSelectors.publishManageModal().should('be.visible'); - ShareSelectors.publishManagePanel().should('be.visible').contains('Namespace'); - - cy.get('body').type('{esc}'); - ShareSelectors.publishManageModal().should('not.exist'); - }); - }); - it('publish database (To-dos) and visit published link', () => { cy.on('uncaught:exception', (err: Error) => { if (err.message.includes('No workspace or service found') || diff --git a/cypress/fixtures/appflowy.png b/cypress/fixtures/appflowy.png deleted file mode 100644 index f37764b1f..000000000 Binary files a/cypress/fixtures/appflowy.png and /dev/null differ diff --git a/cypress/support/selectors.ts b/cypress/support/selectors.ts index 37fb48325..f8a1aab55 100644 --- a/cypress/support/selectors.ts +++ b/cypress/support/selectors.ts @@ -177,7 +177,6 @@ export const ShareSelectors = { // Publish namespace and name inputs publishNamespace: () => cy.get(byTestId('publish-namespace')), publishNameInput: () => cy.get(byTestId('publish-name-input')), - openPublishSettingsButton: () => cy.get(byTestId('open-publish-settings')), // Page settings button pageSettingsButton: () => cy.get(byTestId('page-settings-button')), @@ -196,8 +195,6 @@ export const ShareSelectors = { // Visit Site button visitSiteButton: () => cy.get(byTestId('visit-site-button')), - publishManageModal: () => cy.get(byTestId('publish-manage-modal')), - publishManagePanel: () => cy.get(byTestId('publish-manage-panel')), }; /** diff --git a/deploy/server.ts b/deploy/server.ts index 64cfe06ba..b61958268 100644 --- a/deploy/server.ts +++ b/deploy/server.ts @@ -20,33 +20,27 @@ const setOrUpdateMetaTag = ($: CheerioAPI, selector: string, attribute: string, } }; -const prettyTransport = { - target: 'pino-pretty', - options: { - colorize: true, - translateTime: 'SYS:standard', - }, -}; - const logger = pino({ - transport: process.env.NODE_ENV === 'production' ? undefined : prettyTransport, - level: process.env.LOG_LEVEL || 'info', + transport: { + target: 'pino-pretty', + options: { + colorize: true, + translateTime: 'SYS:standard', + destination: `${__dirname}/pino-logger.log`, + }, + }, + level: 'info', }); const logRequestTimer = (req: Request) => { const start = Date.now(); const pathname = new URL(req.url).pathname; - if (!pathname.startsWith('/health')) { - logger.debug(`Incoming request: ${pathname}`); - } - + logger.info(`Incoming request: ${pathname}`); return () => { const duration = Date.now() - start; - if (!pathname.startsWith('/health')) { - logger.debug(`Request for ${pathname} took ${duration}ms`); - } + logger.info(`Request for ${pathname} took ${duration}ms`); }; }; @@ -57,10 +51,10 @@ const fetchMetaData = async (namespace: string, publishName?: string) => { url = `${baseURL}/api/workspace/v1/published/${namespace}/${publishName}`; } - logger.debug(`Fetching meta data from ${url}`); + logger.info(`Fetching meta data from ${url}`); try { const response = await fetch(url, { - verbose: false, + verbose: true, }); if (!response.ok) { @@ -69,11 +63,11 @@ const fetchMetaData = async (namespace: string, publishName?: string) => { const data = await response.json(); - logger.debug(`Fetched meta data from ${url}: ${JSON.stringify(data)}`); + logger.info(`Fetched meta data from ${url}: ${JSON.stringify(data)}`); return data; } catch (error) { - logger.error(`Failed to fetch meta data from ${url}: ${error}`); + logger.error(`Error fetching meta data ${error}`); return null; } }; @@ -83,9 +77,7 @@ const createServer = async (req: Request) => { const reqUrl = new URL(req.url); const hostname = req.headers.get('host'); - if (!reqUrl.pathname.startsWith('/health')) { - logger.info(`Request URL: ${hostname}${reqUrl.pathname}`); - } + logger.info(`Request URL: ${hostname}${reqUrl.pathname}`); if (reqUrl.pathname === '/') { timer(); @@ -124,7 +116,7 @@ const createServer = async (req: Request) => { const [namespace, publishName] = reqUrl.pathname.slice(1).split('/'); - logger.debug(`Namespace: ${namespace}, Publish Name: ${publishName}`); + logger.info(`Namespace: ${namespace}, Publish Name: ${publishName}`); if (req.method === 'GET') { if (namespace === '') { @@ -138,27 +130,24 @@ const createServer = async (req: Request) => { } let metaData; - let redirectAttempted = false; try { const data = await fetchMetaData(namespace, publishName); if (publishName) { - if (data && data.code === 0) { + if (data.code === 0) { metaData = data.data; } else { - logger.error( - `Publish view lookup failed for namespace="${namespace}" publishName="${publishName}" response=${JSON.stringify(data)}` - ); + logger.error(`Error fetching meta data: ${JSON.stringify(data)}`); } } else { + const publishInfo = data?.data?.info; - if (publishInfo?.namespace && publishInfo?.publish_name) { + if (publishInfo) { const newURL = `/${encodeURIComponent(publishInfo.namespace)}/${encodeURIComponent(publishInfo.publish_name)}`; - logger.debug(`Redirecting to default page in: ${JSON.stringify(publishInfo)}`); - redirectAttempted = true; + logger.info(`Redirecting to default page in: ${JSON.stringify(publishInfo)}`); timer(); return new Response(null, { status: 302, @@ -166,8 +155,6 @@ const createServer = async (req: Request) => { Location: newURL, }, }); - } else { - logger.warn(`Namespace "${namespace}" has no default publish page. response=${JSON.stringify(data)}`); } } } catch (error) { @@ -232,12 +219,6 @@ const createServer = async (req: Request) => { logger.error(`Error injecting meta data: ${error}`); } - if (!metaData) { - logger.warn( - `Serving fallback landing page for namespace="${namespace}" publishName="${publishName ?? ''}". redirectAttempted=${redirectAttempted}` - ); - } - $('title').text(title); $('link[rel="icon"]').attr('href', favicon); $('link[rel="canonical"]').attr('href', url); diff --git a/docker/Dockerfile.ssr b/docker/Dockerfile.ssr index 43ae5fdb7..690feea76 100644 --- a/docker/Dockerfile.ssr +++ b/docker/Dockerfile.ssr @@ -2,7 +2,6 @@ # Build stage - Build the React application FROM node:20.12.0-alpine AS builder -ARG VERSION=dev ENV NODE_ENV=production WORKDIR /app @@ -47,10 +46,6 @@ COPY docker/entrypoint-ssr.sh /docker-entrypoint.sh # Make entrypoint executable RUN chmod +x /docker-entrypoint.sh -# Set version as environment variable (from build arg) -ARG VERSION -ENV APP_VERSION=${VERSION} - # Expose port 80 EXPOSE 80 diff --git a/docker/entrypoint-ssr.sh b/docker/entrypoint-ssr.sh index a021e770d..6b120784d 100644 --- a/docker/entrypoint-ssr.sh +++ b/docker/entrypoint-ssr.sh @@ -1,12 +1,6 @@ #!/bin/sh set -e -# Print version banner -echo "════════════════════════════════════════════════════════════════════" -echo " AppFlowy Web v${APP_VERSION:-dev}" -echo "════════════════════════════════════════════════════════════════════" -echo "" - # Backward compatibility: Map old environment variable names to new ones if [ -n "${AF_BASE_URL}" ] && [ -z "${APPFLOWY_BASE_URL}" ]; then echo "⚠️ WARNING: AF_BASE_URL is deprecated. Please use APPFLOWY_BASE_URL instead." diff --git a/docker/supervisord-ssr.conf b/docker/supervisord-ssr.conf index 8fade87e1..e445d944a 100644 --- a/docker/supervisord-ssr.conf +++ b/docker/supervisord-ssr.conf @@ -19,9 +19,7 @@ priority=10 command=bun run /app/server.ts autostart=true autorestart=true -stdout_logfile=/dev/stdout -stdout_logfile_maxbytes=0 -stderr_logfile=/dev/stderr -stderr_logfile_maxbytes=0 +stdout_logfile=/var/log/bun.out.log +stderr_logfile=/var/log/bun.err.log environment=NODE_ENV="production" priority=20 \ No newline at end of file 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/_shared/landing-page/InvalidLink.stories.tsx b/src/components/_shared/landing-page/InvalidLink.stories.tsx deleted file mode 100644 index 5685c5cc2..000000000 --- a/src/components/_shared/landing-page/InvalidLink.stories.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/react-vite'; - -import { withContextsMinimal } from '../../../../.storybook/decorators'; - -import { InvalidLink } from './InvalidLink'; - -const meta = { - title: 'Landing Pages/InvalidLink', - component: InvalidLink, - parameters: { - layout: 'fullscreen', - }, - decorators: [withContextsMinimal], - tags: ['autodocs'], -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const ExpiredMessage: Story = { - args: { - message: 'The invitation for Tom Workspace expired on 2024-05-01 10:00 UTC.', - }, -}; - -export const AlreadyAccepted: Story = { - args: { - message: 'This invitation was already accepted. Ask the workspace admin to send a new one.', - }, -}; - -export const Declined: Story = { - args: { - message: 'This invitation was declined earlier and can no longer be used.', - }, -}; - -export const DisabledLink: Story = { - args: { - message: 'This invite link was disabled by the workspace admin. Please request a new link.', - }, -}; - -export const NoActiveMembers: Story = { - args: { - message: 'Tom Workspace currently has no active members, so its invite link is disabled.', - }, -}; diff --git a/src/components/_shared/landing-page/InvalidLink.tsx b/src/components/_shared/landing-page/InvalidLink.tsx index 646e0a3e8..775f0a07c 100644 --- a/src/components/_shared/landing-page/InvalidLink.tsx +++ b/src/components/_shared/landing-page/InvalidLink.tsx @@ -3,18 +3,14 @@ import { useTranslation } from 'react-i18next'; import { ReactComponent as InvalidLinkLogo } from '@/assets/icons/invalid_link.svg'; import LandingPage from '@/components/_shared/landing-page/LandingPage'; -type InvalidLinkProps = { - message?: string; -}; - -export function InvalidLink({ message }: InvalidLinkProps) { +export function InvalidLink() { const { t } = useTranslation(); return ( window.open('/app', '_self'), label: t('landingPage.backToHome'), diff --git a/src/components/_shared/mobile-drawer/MobileDrawer.tsx b/src/components/_shared/mobile-drawer/MobileDrawer.tsx index 8d5ccde65..cd8e0ebdf 100644 --- a/src/components/_shared/mobile-drawer/MobileDrawer.tsx +++ b/src/components/_shared/mobile-drawer/MobileDrawer.tsx @@ -77,7 +77,6 @@ export function MobileDrawer ({ swipeAreaHeight, maxHeight, showPuller = true, - topOffset = 0, }: { children: React.ReactNode; triggerNode: ReactElement; @@ -89,7 +88,6 @@ export function MobileDrawer ({ swipeAreaHeight?: number | undefined; maxHeight?: number | undefined; showPuller?: boolean; - topOffset?: number; }) { const toggleDrawer = useCallback((open: boolean) => { @@ -120,22 +118,6 @@ export function MobileDrawer ({ ); - const paperStyle: React.CSSProperties = { - width: swipeAreaWidth, - height: swipeAreaHeight, - maxHeight: maxHeight, - }; - - if (topOffset && (anchor === 'left' || anchor === 'right')) { - paperStyle.top = topOffset; - - if (!swipeAreaHeight) { - paperStyle.height = `calc(100% - ${topOffset}px)`; - } else if (typeof swipeAreaHeight === 'number') { - paperStyle.height = Math.max(0, swipeAreaHeight - topOffset); - } - } - return ( <> {React.cloneElement(triggerNode, { ...triggerNode.props, onClick: toggleDrawer(true) })} @@ -150,7 +132,11 @@ export function MobileDrawer ({ }, }} PaperProps={{ - style: paperStyle, + style: { + width: swipeAreaWidth, + height: swipeAreaHeight, + maxHeight: maxHeight, + }, }} > {drawerContent} @@ -159,4 +145,4 @@ export function MobileDrawer ({ ); } -export default MobileDrawer; +export default MobileDrawer; \ No newline at end of file diff --git a/src/components/_shared/mobile-topbar/MobileFolder.tsx b/src/components/_shared/mobile-topbar/MobileFolder.tsx index 064299da2..3e41f9ff1 100644 --- a/src/components/_shared/mobile-topbar/MobileFolder.tsx +++ b/src/components/_shared/mobile-topbar/MobileFolder.tsx @@ -29,8 +29,8 @@ function MobileFolder({ onClose }: { onClose: () => void }) { return (
-
-
+
+
diff --git a/src/components/_shared/mobile-topbar/MobileTopBar.tsx b/src/components/_shared/mobile-topbar/MobileTopBar.tsx index 1ad692bbb..22f4c1717 100644 --- a/src/components/_shared/mobile-topbar/MobileTopBar.tsx +++ b/src/components/_shared/mobile-topbar/MobileTopBar.tsx @@ -1,5 +1,5 @@ import { IconButton } from '@mui/material'; -import React, { useCallback, useMemo } from 'react'; +import React, { useCallback } from 'react'; import { HEADER_HEIGHT } from '@/application/constants'; import { UIVariant } from '@/application/types'; @@ -14,7 +14,6 @@ import MobileFolder from '@/components/_shared/mobile-topbar/MobileFolder'; import PublishMobileFolder from '@/components/_shared/mobile-topbar/PublishMobileFolder'; import MoreActionsContent from '@/components/_shared/more-actions/MoreActionsContent'; import { openOrDownload } from '@/utils/open_schema'; -import { getPlatform } from '@/utils/platform'; const PublishBreadcrumb = withPublishBreadcrumb(Breadcrumb); const AppBreadcrumb = withAppBreadcrumb(Breadcrumb); @@ -22,15 +21,6 @@ const AppBreadcrumb = withAppBreadcrumb(Breadcrumb); function MobileTopBar({ variant }: { variant?: UIVariant }) { const [openFolder, setOpenFolder] = React.useState(false); const [openMore, setOpenMore] = React.useState(false); - const isMobile = getPlatform().isMobile; - const folderDrawerWidth = useMemo(() => { - if (typeof window === 'undefined') return undefined; - const availableWidth = Math.max(0, window.innerWidth - 56); - - if (isMobile) return availableWidth; - - return Math.min(420, availableWidth); - }, [isMobile]); const handleOpenFolder = useCallback(() => { setOpenFolder(true); @@ -60,13 +50,12 @@ function MobileTopBar({ variant }: { variant?: UIVariant }) { } > 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/app/landing-pages/ApproveConversion.tsx b/src/components/app/landing-pages/ApproveConversion.tsx index 8a33cb525..a951d08bf 100644 --- a/src/components/app/landing-pages/ApproveConversion.tsx +++ b/src/components/app/landing-pages/ApproveConversion.tsx @@ -29,7 +29,6 @@ export function ApproveConversion() { const [guestName, setGuestName] = useState(); const [isInvalid, setIsInvalid] = useState(false); - const [invalidMessage, setInvalidMessage] = useState(); const [notInvitee, setNotInvitee] = useState(false); @@ -68,7 +67,6 @@ export function ApproveConversion() { // eslint-disable-next-line } catch (e: any) { if (e.code === ERROR_CODE.INVALID_LINK) { - setInvalidMessage(e.message); setIsInvalid(true); } else if (e.code === ERROR_CODE.ALREADY_JOINED) { setIsAlreadyMember(true); @@ -147,7 +145,7 @@ export function ApproveConversion() { } if (isInvalid) { - return ; + return ; } if (notInvitee) { diff --git a/src/components/app/landing-pages/AsGuest.tsx b/src/components/app/landing-pages/AsGuest.tsx index e523b7acd..3bac7591f 100644 --- a/src/components/app/landing-pages/AsGuest.tsx +++ b/src/components/app/landing-pages/AsGuest.tsx @@ -24,7 +24,6 @@ export function AsGuest() { const [page, setPage] = useState<{ view_id: string; name: string } | null>(null); const [isInvalid, setIsInvalid] = useState(false); - const [invalidMessage, setInvalidMessage] = useState(); const [notInvitee, setNotInvitee] = useState(false); @@ -64,7 +63,6 @@ export function AsGuest() { // eslint-disable-next-line } catch (e: any) { if (e.code === ERROR_CODE.INVALID_LINK) { - setInvalidMessage(e.message); setIsInvalid(true); } else if (e.code === ERROR_CODE.ALREADY_JOINED) { // do nothing @@ -83,7 +81,7 @@ export function AsGuest() { }, [loadInvitation]); if (isInvalid) { - return ; + return ; } if (notInvitee) { diff --git a/src/components/app/landing-pages/InviteCode.tsx b/src/components/app/landing-pages/InviteCode.tsx index 7d5a61340..024b41451 100644 --- a/src/components/app/landing-pages/InviteCode.tsx +++ b/src/components/app/landing-pages/InviteCode.tsx @@ -21,7 +21,6 @@ function InviteCode() { const [hasJoined, setHasJoined] = useState(false); const [isInValid, setIsInValid] = useState(false); - const [invalidMessage, setInvalidMessage] = useState(); const [workspace, setWorkspace] = useState(); const [isError, setIsError] = useState(false); @@ -51,7 +50,6 @@ function InviteCode() { // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (e: any) { if (e.code === ERROR_CODE.INVALID_LINK) { - setInvalidMessage(e.message); setIsInValid(true); } else { setIsError(true); @@ -81,7 +79,6 @@ function InviteCode() { // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (e: any) { if (e.code === ERROR_CODE.INVALID_LINK) { - setInvalidMessage(e.message); setIsInValid(true); } else { setIsError(true); @@ -104,7 +101,7 @@ function InviteCode() { ); if (isInValid) { - return ; + return ; } if (isError) { diff --git a/src/components/app/publish-manage/PublishManage.tsx b/src/components/app/publish-manage/PublishManage.tsx index e0092ec0a..2dae7545b 100644 --- a/src/components/app/publish-manage/PublishManage.tsx +++ b/src/components/app/publish-manage/PublishManage.tsx @@ -12,7 +12,6 @@ import PublishedPages from '@/components/app/publish-manage/PublishedPages'; import PublishPagesSkeleton from '@/components/app/publish-manage/PublishPagesSkeleton'; import UpdateNamespace from '@/components/app/publish-manage/UpdateNamespace'; import { useCurrentUser, useService } from '@/components/main/app.hooks'; -import { isOfficialHost } from '@/utils/subscription'; import { openUrl } from '@/utils/url'; export function PublishManage({ onClose }: { onClose?: () => void }) { @@ -176,11 +175,6 @@ export function PublishManage({ onClose }: { onClose?: () => void }) { const { getSubscriptions } = useAppHandlers(); const [activeSubscription, setActiveSubscription] = React.useState(null); const loadSubscription = useCallback(async () => { - if (!isOfficialHost()) { - setActiveSubscription(SubscriptionPlan.Pro); - return; - } - try { const subscriptions = await getSubscriptions?.(); @@ -215,7 +209,7 @@ export function PublishManage({ onClose }: { onClose?: () => void }) { const url = `${window.location.origin}/${namespace}`; return ( -
+
{t('namespace')}
{t('manageNamespaceDescription')}
diff --git a/src/components/app/share/PublishLinkPreview.tsx b/src/components/app/share/PublishLinkPreview.tsx index 3d6fc1df4..b410c7ce6 100644 --- a/src/components/app/share/PublishLinkPreview.tsx +++ b/src/components/app/share/PublishLinkPreview.tsx @@ -5,7 +5,9 @@ import { useTranslation } from 'react-i18next'; import { UpdatePublishConfigPayload } from '@/application/types'; import { ReactComponent as LinkIcon } from '@/assets/icons/link.svg'; import { ReactComponent as DownIcon } from '@/assets/icons/toggle_list.svg'; +import { NormalModal } from '@/components/_shared/modal'; import { notify } from '@/components/_shared/notify'; +import { PublishManage } from '@/components/app/publish-manage'; import { PublishNameSetting } from '@/components/app/publish-manage/PublishNameSetting'; import { copyTextToClipboard } from '@/utils/copy'; @@ -18,7 +20,6 @@ function PublishLinkPreview({ isOwner, isPublisher, onClose, - onOpenPublishManage, }: { viewId: string; publishInfo: { namespace: string; publishName: string }; @@ -28,8 +29,8 @@ function PublishLinkPreview({ isOwner: boolean; isPublisher: boolean; onClose?: () => void; - onOpenPublishManage?: () => void; }) { + const [siteOpen, setSiteOpen] = React.useState(false); const [renameOpen, setRenameOpen] = React.useState(false); const { t } = useTranslation(); const [publishName, setPublishName] = React.useState(publishInfo.publishName); @@ -77,8 +78,8 @@ function PublishLinkPreview({ { + setSiteOpen(true); onClose?.(); - onOpenPublishManage?.(); }} data-testid={'open-publish-settings'} > @@ -160,6 +161,32 @@ function PublishLinkPreview({ url={url} /> )} + { + setSiteOpen(false); + }} + scroll={'paper'} + open={siteOpen} + title={
{t('settings.sites.title')}
} + > +
+ { + setSiteOpen(false); + }} + /> +
+
); diff --git a/src/components/app/share/PublishPanel.tsx b/src/components/app/share/PublishPanel.tsx index 1bfaaccd4..90aa088f5 100644 --- a/src/components/app/share/PublishPanel.tsx +++ b/src/components/app/share/PublishPanel.tsx @@ -13,17 +13,7 @@ import { useAppHandlers } from '@/components/app/app.hooks'; import { useLoadPublishInfo } from '@/components/app/share/publish.hooks'; import PublishLinkPreview from '@/components/app/share/PublishLinkPreview'; -function PublishPanel({ - viewId, - opened, - onClose, - onOpenPublishManage, -}: { - viewId: string; - onClose: () => void; - opened: boolean; - onOpenPublishManage?: () => void; -}) { +function PublishPanel({ viewId, opened, onClose }: { viewId: string; onClose: () => void; opened: boolean }) { const { t } = useTranslation(); const { publish, unpublish } = useAppHandlers(); const { url, loadPublishInfo, view, publishInfo, loading, isOwner, isPublisher, updatePublishConfig } = @@ -102,7 +92,6 @@ function PublishPanel({ isOwner={isOwner} isPublisher={isPublisher} onClose={onClose} - onOpenPublishManage={onOpenPublishManage} />
- - - setOpened(false)} - onOpenPublishManage={() => { - setOpened(false); - setPublishManageOpen(true); - }} - /> - - - setPublishManageOpen(false)} - scroll='paper' - overflowHidden - okButtonProps={{ - className: 'hidden', - }} - cancelButtonProps={{ - className: 'hidden', - }} - classes={{ - paper: 'w-[700px] appflowy-scroller max-w-[90vw] max-h-[90vh] h-[600px] overflow-hidden', - }} - title={
{t('settings.sites.title')}
} + + + + + -
- setPublishManageOpen(false)} /> -
-
- + setOpened(false)} /> + + ); } diff --git a/src/components/app/share/SharePanel.tsx b/src/components/app/share/SharePanel.tsx index 08e355d64..701326845 100644 --- a/src/components/app/share/SharePanel.tsx +++ b/src/components/app/share/SharePanel.tsx @@ -10,7 +10,6 @@ import { InviteGuest } from '@/components/app/share/InviteGuest'; import { PeopleWithAccess } from '@/components/app/share/PeopleWithAccess'; import { UpgradeBanner } from '@/components/app/share/UpgradeBanner'; import { useCurrentUser, useService } from '@/components/main/app.hooks'; -import { isOfficialHost } from '@/utils/subscription'; function SharePanel({ viewId }: { viewId: string }) { const currentUser = useCurrentUser(); @@ -123,11 +122,6 @@ function SharePanel({ viewId }: { viewId: string }) { }, [getSubscriptions]); useEffect(() => { - if (!isOfficialHost()) { - setActiveSubscriptionPaln(SubscriptionPlan.Pro); - return; - } - if (isOwner || isMember) { void loadSubscription(); } @@ -147,7 +141,7 @@ function SharePanel({ viewId }: { viewId: string }) { hasFullAccess={hasFullAccess} activeSubscriptionPlan={activeSubscriptionPlan} /> - {isOfficialHost() && } + diff --git a/src/components/app/share/ShareTabs.tsx b/src/components/app/share/ShareTabs.tsx index ba76be0e7..87f306e4f 100644 --- a/src/components/app/share/ShareTabs.tsx +++ b/src/components/app/share/ShareTabs.tsx @@ -17,17 +17,7 @@ enum TabKey { TEMPLATE = 'template', } -function ShareTabs({ - opened, - viewId, - onClose, - onOpenPublishManage, -}: { - opened: boolean; - viewId: string; - onClose: () => void; - onOpenPublishManage?: () => void; -}) { +function ShareTabs({ opened, viewId, onClose }: { opened: boolean; viewId: string; onClose: () => void }) { const { t } = useTranslation(); const view = useAppView(viewId); const [value, setValue] = React.useState(TabKey.SHARE); @@ -53,19 +43,12 @@ function ShareTabs({ icon: , Panel: TemplatePanel, }, - ].filter(Boolean) as Array< - { - value: TabKey; - label: string; - icon?: React.JSX.Element; - Panel: React.FC<{ - viewId: string; - onClose: () => void; - opened: boolean; - onOpenPublishManage?: () => void; - }>; - } - >; + ].filter(Boolean) as { + value: TabKey; + label: string; + icon?: React.JSX.Element; + Panel: React.FC<{ viewId: string; onClose: () => void; opened: boolean }>; + }[]; }, [currentUser?.email, t, view?.is_published]); useEffect(() => { @@ -93,12 +76,7 @@ function ShareTabs({ {options.map((option) => ( - + ))} diff --git a/src/components/app/workspaces/CurrentWorkspace.tsx b/src/components/app/workspaces/CurrentWorkspace.tsx index 782120f4c..647182940 100644 --- a/src/components/app/workspaces/CurrentWorkspace.tsx +++ b/src/components/app/workspaces/CurrentWorkspace.tsx @@ -8,12 +8,11 @@ function CurrentWorkspace({ selectedWorkspace, onChangeWorkspace, changeLoading, - avatarSize = 'xs', }: { userWorkspaceInfo?: UserWorkspaceInfo; selectedWorkspace?: Workspace; onChangeWorkspace: (selectedId: string) => void; - avatarSize?: 'xs' | 'sm' | 'md' | 'xl'; + avatarSize?: number; changeLoading?: boolean; }) { if (!userWorkspaceInfo || !selectedWorkspace) { @@ -34,21 +33,19 @@ function CurrentWorkspace({ } return ( -
- {( - - - - {selectedWorkspace.icon ? {selectedWorkspace.icon} : selectedWorkspace.name} - - - )} + <> + + + + {selectedWorkspace.icon ? {selectedWorkspace.icon} : selectedWorkspace.name} + +
{selectedWorkspace.name}
{changeLoading && } -
+ ); } diff --git a/src/components/app/workspaces/InviteMember.tsx b/src/components/app/workspaces/InviteMember.tsx index e3ebd8524..f61206f60 100644 --- a/src/components/app/workspaces/InviteMember.tsx +++ b/src/components/app/workspaces/InviteMember.tsx @@ -12,7 +12,6 @@ import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from ' import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Progress } from '@/components/ui/progress'; -import { isOfficialHost } from '@/utils/subscription'; function InviteMember({ workspace, @@ -49,11 +48,6 @@ function InviteMember({ const [activeSubscriptionPlan, setActiveSubscriptionPaln] = React.useState(null); const loadSubscription = useCallback(async () => { - if (!isOfficialHost()) { - setActiveSubscriptionPaln(SubscriptionPlan.Pro); - return; - } - try { const subscriptions = await getSubscriptions?.(); diff --git a/src/components/app/workspaces/MobileWorkspaces.tsx b/src/components/app/workspaces/MobileWorkspaces.tsx index 4367429b5..7a609ad98 100644 --- a/src/components/app/workspaces/MobileWorkspaces.tsx +++ b/src/components/app/workspaces/MobileWorkspaces.tsx @@ -104,7 +104,6 @@ function MobileWorkspaces({ onClose }: { onClose: () => void }) { onChange={handleChange} changeLoading={changeLoading || undefined} showActions={false} - useDropdownItem={false} /> )}
diff --git a/src/components/app/workspaces/WorkspaceItem.tsx b/src/components/app/workspaces/WorkspaceItem.tsx index c96e02897..9c88622f4 100644 --- a/src/components/app/workspaces/WorkspaceItem.tsx +++ b/src/components/app/workspaces/WorkspaceItem.tsx @@ -1,11 +1,11 @@ import { CircularProgress } from '@mui/material'; -import { useCallback, useMemo, useState } from 'react'; +import { useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Role, Workspace } from '@/application/types'; import MoreActions from '@/components/app/workspaces/MoreActions'; import { Avatar, AvatarFallback } from '@/components/ui/avatar'; -import { DropdownMenuItem, DropdownMenuItemTick, dropdownMenuItemVariants } from '@/components/ui/dropdown-menu'; +import { DropdownMenuItem, DropdownMenuItemTick } from '@/components/ui/dropdown-menu'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; export function WorkspaceItem({ @@ -17,7 +17,6 @@ export function WorkspaceItem({ onUpdate, onDelete, onLeave, - useDropdownItem = true, }: { showActions?: boolean; workspace: Workspace; @@ -27,7 +26,6 @@ export function WorkspaceItem({ onUpdate?: (workspace: Workspace) => void; onDelete?: (workspace: Workspace) => void; onLeave?: (workspace: Workspace) => void; - useDropdownItem?: boolean; }) { const { t } = useTranslation(); const [hovered, setHovered] = useState(false); @@ -71,13 +69,18 @@ export function WorkspaceItem({ ); }, [changeLoading, currentWorkspaceId, hovered, onDelete, onLeave, onUpdate, showActions, workspace]); - const handleSelect = useCallback(() => { - if (workspace.id === currentWorkspaceId) return; - void onChange(workspace.id); - }, [currentWorkspaceId, onChange, workspace.id]); - - const content = ( - <> + return ( + { + if (workspace.id === currentWorkspaceId) return; + void onChange(workspace.id); + }} + onMouseEnter={() => setHovered(true)} + onMouseLeave={() => setHovered(false)} + > {workspace.icon ? {workspace.icon} : workspace.name} @@ -109,35 +112,6 @@ export function WorkspaceItem({ )}
{renderActions} - - ); - - if (useDropdownItem) { - return ( - setHovered(true)} - onMouseLeave={() => setHovered(false)} - > - {content} - - ); - } - - return ( - + ); } diff --git a/src/components/app/workspaces/WorkspaceList.tsx b/src/components/app/workspaces/WorkspaceList.tsx index a308360d2..143c47c80 100644 --- a/src/components/app/workspaces/WorkspaceList.tsx +++ b/src/components/app/workspaces/WorkspaceList.tsx @@ -13,7 +13,6 @@ function WorkspaceList({ onUpdate, onDelete, onLeave, - useDropdownItem = true, }: { currentWorkspaceId?: string; changeLoading?: string; @@ -24,7 +23,6 @@ function WorkspaceList({ onUpdate?: (workspace: Workspace) => void; onDelete?: (workspace: Workspace) => void; onLeave?: (workspace: Workspace) => void; - useDropdownItem?: boolean; }) { const service = useService(); const [workspaces, setWorkspaces] = useState(defaultWorkspaces || []); @@ -57,7 +55,6 @@ function WorkspaceList({ onDelete={onDelete} onLeave={onLeave} showActions={showActions} - useDropdownItem={useDropdownItem} /> ); })} diff --git a/src/components/app/workspaces/Workspaces.tsx b/src/components/app/workspaces/Workspaces.tsx index a169a0e49..075ee4e25 100644 --- a/src/components/app/workspaces/Workspaces.tsx +++ b/src/components/app/workspaces/Workspaces.tsx @@ -146,7 +146,7 @@ export function Workspaces() { userWorkspaceInfo={userWorkspaceInfo} selectedWorkspace={currentWorkspace} onChangeWorkspace={handleChange} - avatarSize='sm' + avatarSize={24} changeLoading={changeLoading ? true : false} /> 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/billing/UpgradePlan.stories.tsx b/src/components/billing/UpgradePlan.stories.tsx index 7b083ca73..5db1efc05 100644 --- a/src/components/billing/UpgradePlan.stories.tsx +++ b/src/components/billing/UpgradePlan.stories.tsx @@ -1,10 +1,10 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { AppContext } from '@/components/app/app.hooks'; import { AFConfigContext } from '@/components/main/app.hooks'; import { hostnameArgType, openArgType } from '../../../.storybook/argTypes'; -import { useHostnameMock } from '../../../.storybook/decorators'; +import { mockHostname } from '../../../.storybook/decorators'; import { mockAFConfigValue, mockAppContextValue } from '../../../.storybook/mocks'; import UpgradePlan from './UpgradePlan'; @@ -23,7 +23,17 @@ const meta = { const hostname = context.args.hostname || 'beta.appflowy.cloud'; const [open, setOpen] = useState(context.args.open ?? false); - useHostnameMock(hostname); + // Set mock hostname synchronously before render + mockHostname(hostname); + + useEffect(() => { + // Update if hostname changes + mockHostname(hostname); + // Cleanup + return () => { + delete window.__STORYBOOK_MOCK_HOSTNAME__; + }; + }, [hostname]); return ( @@ -86,3 +96,4 @@ export const SelfHosted: Story = { }, }, }; + 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/blocks/file/FileToolbar.tsx b/src/components/editor/components/blocks/file/FileToolbar.tsx index 17537b54d..b1bc1b4da 100644 --- a/src/components/editor/components/blocks/file/FileToolbar.tsx +++ b/src/components/editor/components/blocks/file/FileToolbar.tsx @@ -6,12 +6,15 @@ import { useReadOnly, useSlateStatic } from 'slate-react'; import { YjsEditor } from '@/application/slate-yjs'; import { CustomEditor } from '@/application/slate-yjs/command'; +import { ReactComponent as CopyIcon } from '@/assets/icons/copy.svg'; import { ReactComponent as DeleteIcon } from '@/assets/icons/delete.svg'; import { ReactComponent as EditIcon } from '@/assets/icons/edit.svg'; import { ReactComponent as DownloadIcon } from '@/assets/icons/save_as.svg'; import { NormalModal } from '@/components/_shared/modal'; +import { notify } from '@/components/_shared/notify'; import ActionButton from '@/components/editor/components/toolbar/selection-toolbar/actions/ActionButton'; import { FileNode } from '@/components/editor/editor.type'; +import { copyTextToClipboard } from '@/utils/copy'; import { downloadFile } from '@/utils/download'; function FileToolbar({ node }: { node: FileNode }) { @@ -22,6 +25,10 @@ function FileToolbar({ node }: { node: FileNode }) { const name = node.data.name || ''; const [open, setOpen] = useState(false); const [fileName, setFileName] = useState(name); + const onCopy = async () => { + await copyTextToClipboard(node.data.url || ''); + notify.success(t('publish.copy.fileBlock')); + }; const onDelete = () => { CustomEditor.deleteBlock(editor, node.blockId); @@ -54,6 +61,10 @@ function FileToolbar({ node }: { node: FileNode }) { + + + + {!readOnly && ( <> { - const blob = await fetchImageBlob(node.data.url || ''); + const onCopy = async () => { + try { + const blob = await fetchImageBlob(node.data.url || ''); - if (blob) { - try { - await navigator.clipboard.write([ - new ClipboardItem({ - [blob.type]: blob, - }), - ]); - notify.success(t('document.plugins.image.copiedToPasteBoard')); - } catch (error) { - notify.error('Failed to copy image'); + if (!blob) { + throw new Error('Failed to fetch image blob'); } + + await navigator.clipboard.write([ + new ClipboardItem({ + [blob.type]: blob, + }), + ]); + notify.success(t('document.plugins.image.copiedToPasteBoard')); + } catch (error) { + notify.error(t('document.plugins.image.copyFailed', { defaultValue: 'Failed to copy image' })); } }; @@ -57,7 +59,7 @@ function ImageToolbar({ node }: { node: ImageBlockNode }) { )} - + 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/pages/AcceptInvitationPage.tsx b/src/pages/AcceptInvitationPage.tsx index f36c62d9a..5370365ea 100644 --- a/src/pages/AcceptInvitationPage.tsx +++ b/src/pages/AcceptInvitationPage.tsx @@ -27,7 +27,6 @@ function AcceptInvitationPage() { const [loading, setLoading] = useState(false); const [notInvitee, setNotInvitee] = useState(false); const [isError, setIsError] = useState(false); - const [invalidMessage, setInvalidMessage] = useState(); const loadInvitation = useCallback(async () => { if (!service) return; @@ -52,7 +51,6 @@ function AcceptInvitationPage() { } if (e.code === ERROR_CODE.INVALID_LINK) { - setInvalidMessage(e.message); setIsInValid(true); return; } @@ -100,7 +98,6 @@ function AcceptInvitationPage() { // eslint-disable-next-line } catch (e: any) { if (e.code === ERROR_CODE.INVALID_LINK) { - setInvalidMessage(e.message); setIsInValid(true); return; } @@ -129,7 +126,7 @@ function AcceptInvitationPage() { ); if (isInValid) { - return ; + return ; } if (notInvitee) { 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'; diff --git a/src/utils/image.ts b/src/utils/image.ts index e013d8b98..83e489a81 100644 --- a/src/utils/image.ts +++ b/src/utils/image.ts @@ -107,37 +107,27 @@ export const checkImage = async (url: string) => { }; export const fetchImageBlob = async (url: string): Promise => { - if (isAppFlowyFileStorageUrl(url)) { + const isStorageUrl = isAppFlowyFileStorageUrl(url); + let finalUrl = url; + const headers: HeadersInit = {}; + + if (isStorageUrl) { const token = getTokenParsed(); if (!token) return null; - const fullUrl = resolveImageUrl(url); - - try { - const response = await fetch(fullUrl, { - headers: { - Authorization: `Bearer ${token.access_token}`, - }, - }); + finalUrl = resolveImageUrl(url); + headers.Authorization = `Bearer ${token.access_token}`; + } - if (response.ok) { - return await response.blob(); - } - } catch (error) { - return null; - } - } else { - try { - const response = await fetch(url); + try { + const response = await fetch(finalUrl, { + headers, + }); - if (response.ok) { - return await response.blob(); - } - } catch (error) { - return null; - } + if (!response.ok) return null; + return await response.blob(); + } catch { + return null; } - - return null; }; \ No newline at end of file diff --git a/src/utils/subscription.ts b/src/utils/subscription.ts index a7a0f1533..832dad193 100644 --- a/src/utils/subscription.ts +++ b/src/utils/subscription.ts @@ -1,50 +1,21 @@ /** - * Check if the current host is an official AppFlowy host by looking at the backend base URL. - * Official hosts are beta.appflowy.cloud and test.appflowy.cloud. - * Include localhost:8000 to cover the default dev backend when APPFLOWY_BASE_URL isn't updated. - * Self-hosted instances are not official hosts. + * Check if the current host is an official AppFlowy host + * Official hosts are beta.appflowy.cloud, test.appflowy.cloud, and localhost (for development) + * Self-hosted instances are not official hosts */ -import { getConfigValue } from '@/utils/runtime-config'; - -const OFFICIAL_HOSTNAMES = new Set(['beta.appflowy.cloud', 'test.appflowy.cloud', 'localhost']); - -function getBaseUrlHostname(): string | null { - const baseUrl = getConfigValue('APPFLOWY_BASE_URL', '').trim(); - - if (!baseUrl) return null; - - try { - return new URL(baseUrl).hostname; - } catch (primaryError) { - // Allow hostnames without a protocol, e.g. "beta.appflowy.cloud" - try { - return new URL(`https://${baseUrl}`).hostname; - } catch (secondaryError) { - console.warn('Invalid APPFLOWY_BASE_URL provided:', secondaryError); - return null; - } - } -} - -function isOfficialHostname(hostname: string | undefined | null): boolean { - if (!hostname) return false; - return OFFICIAL_HOSTNAMES.has(hostname); -} - -function resolveHostname(): string | null { - const baseUrlHostname = getBaseUrlHostname(); - - if (baseUrlHostname) { - return baseUrlHostname; - } - - if (typeof window === 'undefined') { - return null; - } - - return window.location.hostname; -} - export function isOfficialHost(): boolean { - return isOfficialHostname(resolveHostname()); + if (typeof window === 'undefined') return false; + + // Support Storybook mocking via global variable + const hostname = (window as Window & { __STORYBOOK_MOCK_HOSTNAME__?: string }).__STORYBOOK_MOCK_HOSTNAME__ || window.location.hostname; + + return ( + hostname === 'beta.appflowy.cloud' || + hostname === 'test.appflowy.cloud' + // hostname === 'localhost' || + // hostname === '127.0.0.1' || + // hostname.startsWith('localhost:') || + // hostname.startsWith('127.0.0.1:') + ); } + diff --git a/vite.config.ts b/vite.config.ts index 58084b2b3..9f7efe612 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -10,76 +10,14 @@ import { totalBundleSize } from 'vite-plugin-total-bundle-size'; import { stripTestIdPlugin } from './vite-plugin-strip-testid'; const resourcesPath = path.resolve(__dirname, '../resources'); -const isDev = process.env.NODE_ENV ? process.env.NODE_ENV === 'development' : true; +const isDev = process.env.NODE_ENV === 'development'; const isProd = process.env.NODE_ENV === 'production'; const isTest = process.env.NODE_ENV === 'test' || process.env.COVERAGE === 'true'; -// Namespace redirect plugin for dev mode - mirrors deploy/server.ts behavior -function namespaceRedirectPlugin() { - const baseURL = process.env.APPFLOWY_BASE_URL || 'http://localhost:8000'; - - return { - name: 'namespace-redirect', - apply: 'serve' as const, - configureServer(server: { middlewares: { use: (fn: (req: { url?: string; method?: string }, res: { statusCode: number; setHeader: (name: string, value: string) => void; end: () => void }, next: () => void) => void) => void } }) { - const ignoredPrefixes = ['/app', '/login', '/import', '/after-payment', '/as-template', '/accept-invitation', '/404']; - - server.middlewares.use(async (req, res, next) => { - if (!req.url || req.method !== 'GET') { - return next(); - } - - const url = new URL(req.url, 'http://localhost'); - const pathname = url.pathname; - - // Skip ignored prefixes and root - if (pathname === '/' || ignoredPrefixes.some((prefix) => pathname.startsWith(prefix))) { - return next(); - } - - const parts = pathname.split('/').filter(Boolean); - - // Skip if not a single-segment path (namespace only) or if it's a static asset/dev file - const isStaticAsset = /\.(js|css|html|map|json|png|jpg|jpeg|gif|svg|woff2?|ttf)$/i.test(pathname); - if (parts.length !== 1 || isStaticAsset || pathname.includes('@') || pathname.includes('node_modules') || pathname.startsWith('/src/')) { - return next(); - } - - try { - // Fetch publish info for this namespace (same API as deploy/server.ts) - const apiUrl = `${baseURL}/api/workspace/published/${parts[0]}`; - const response = await fetch(apiUrl); - - if (!response.ok) { - return next(); - } - - const data = await response.json(); - const publishInfo = data?.data?.info; - - if (publishInfo?.namespace && publishInfo?.publish_name) { - const redirectUrl = `/${encodeURIComponent(publishInfo.namespace)}/${encodeURIComponent(publishInfo.publish_name)}`; - res.statusCode = 302; - res.setHeader('Location', redirectUrl); - res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); - res.end(); - return; - } - } catch { - // Silently fail and let the request continue - } - - next(); - }); - }, - }; -} - // https://vitejs.dev/config/ export default defineConfig({ plugins: [ react(), - isDev ? namespaceRedirectPlugin() : undefined, // Strip data-testid attributes in production builds isProd ? stripTestIdPlugin() : undefined, createHtmlPlugin({