From c00d55b3cc51a8488491451f62f29bc8afd02a49 Mon Sep 17 00:00:00 2001 From: Nathan Date: Mon, 24 Nov 2025 23:12:23 +0800 Subject: [PATCH] feat: implement drag and drop --- cypress/e2e/editor/drag_drop_blocks.cy.ts | 260 ++++++++++++++++++ cypress/e2e/page-paste/paste-code.cy.ts | 4 +- cypress/e2e/page-paste/paste-complex.cy.ts | 4 +- cypress/e2e/page-paste/paste-formatting.cy.ts | 4 +- cypress/e2e/page-paste/paste-headings.cy.ts | 4 +- cypress/e2e/page-paste/paste-lists.cy.ts | 10 +- cypress/e2e/page-paste/paste-plain-text.cy.ts | 4 +- cypress/e2e/page-paste/paste-tables.cy.ts | 4 +- package.json | 1 + pnpm-lock.yaml | 17 +- src/application/database-yjs/dispatch.ts | 2 +- .../slate-yjs/plugins/withHistory.ts | 2 +- .../slate-yjs/utils/applyToSlate.ts | 29 +- src/application/slate-yjs/utils/yjs.ts | 9 +- src/application/types.ts | 2 + src/components/app/hooks/useViewNavigation.ts | 1 + src/components/app/hooks/useViewSync.ts | 4 +- src/components/as-template/icons.tsx | 12 +- .../components/ai-writer/view-tree/index.tsx | 2 +- .../components/ai-writer/writing-input.tsx | 8 +- .../chat-input/related-views/index.tsx | 2 +- .../chat-messages/assistant-message.tsx | 3 +- .../components/grid/grid-table/useGridDnd.ts | 2 +- .../components/tabs/DatabaseTabItem.tsx | 5 +- .../components/tabs/DatabaseViewTabs.tsx | 8 +- src/components/editor/Editable.tsx | 21 +- .../components/drag-drop/handleBlockDrop.ts | 92 +++++++ .../components/drag-drop/useBlockDrag.ts | 196 +++++++++++++ .../components/drag-drop/useBlockDrop.ts | 99 +++++++ .../editor/components/drag-drop/validation.ts | 97 +++++++ .../editor/components/element/Element.tsx | 58 +++- .../panels/mention-panel/MentionPanel.tsx | 2 +- .../toolbar/block-controls/ControlActions.tsx | 41 ++- .../block-controls/HoverControls.hooks.ts | 63 ++++- .../toolbar/block-controls/HoverControls.tsx | 42 ++- src/components/editor/editor.scss | 11 +- src/utils/download.ts | 1 + 37 files changed, 1045 insertions(+), 81 deletions(-) create mode 100644 cypress/e2e/editor/drag_drop_blocks.cy.ts create mode 100644 src/components/editor/components/drag-drop/handleBlockDrop.ts create mode 100644 src/components/editor/components/drag-drop/useBlockDrag.ts create mode 100644 src/components/editor/components/drag-drop/useBlockDrop.ts create mode 100644 src/components/editor/components/drag-drop/validation.ts 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 00000000..bf5d1d71 --- /dev/null +++ b/cypress/e2e/editor/drag_drop_blocks.cy.ts @@ -0,0 +1,260 @@ +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' }); + cy.wait(1000); // Wait for hover controls to appear + + // 2. Get the drag handle + cy.get('[data-testid="drag-block"]').should('exist').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/page-paste/paste-code.cy.ts b/cypress/e2e/page-paste/paste-code.cy.ts index 7bfd360e..abd3ac1e 100644 --- a/cypress/e2e/page-paste/paste-code.cy.ts +++ b/cypress/e2e/page-paste/paste-code.cy.ts @@ -1,5 +1,5 @@ -import { createTestPage, pasteContent } from '../../support/paste-utils'; -import { testLog } from '../../support/test-helpers'; +import { createTestPage, pasteContent } from '../../../support/paste-utils'; +import { testLog } from '../../../support/test-helpers'; describe('Paste Code Block Tests', () => { it('should paste all code block formats correctly', () => { diff --git a/cypress/e2e/page-paste/paste-complex.cy.ts b/cypress/e2e/page-paste/paste-complex.cy.ts index 406fcc74..0ae7d113 100644 --- a/cypress/e2e/page-paste/paste-complex.cy.ts +++ b/cypress/e2e/page-paste/paste-complex.cy.ts @@ -1,5 +1,5 @@ -import { createTestPage, pasteContent, verifyEditorContent } from '../../support/paste-utils'; -import { testLog } from '../../support/test-helpers'; +import { createTestPage, pasteContent, verifyEditorContent } from '../../../support/paste-utils'; +import { testLog } from '../../../support/test-helpers'; describe('Paste Complex Content Tests', () => { it('should paste all complex document types correctly', () => { diff --git a/cypress/e2e/page-paste/paste-formatting.cy.ts b/cypress/e2e/page-paste/paste-formatting.cy.ts index a038ffab..299040f7 100644 --- a/cypress/e2e/page-paste/paste-formatting.cy.ts +++ b/cypress/e2e/page-paste/paste-formatting.cy.ts @@ -1,5 +1,5 @@ -import { createTestPage, pasteContent } from '../../support/paste-utils'; -import { testLog } from '../../support/test-helpers'; +import { createTestPage, pasteContent } from '../../../support/paste-utils'; +import { testLog } from '../../../support/test-helpers'; describe('Paste Formatting Tests', () => { it('should paste all formatted content correctly', () => { diff --git a/cypress/e2e/page-paste/paste-headings.cy.ts b/cypress/e2e/page-paste/paste-headings.cy.ts index e843f73d..52c4338f 100644 --- a/cypress/e2e/page-paste/paste-headings.cy.ts +++ b/cypress/e2e/page-paste/paste-headings.cy.ts @@ -1,5 +1,5 @@ -import { createTestPage, pasteContent } from '../../support/paste-utils'; -import { testLog } from '../../support/test-helpers'; +import { createTestPage, pasteContent } from '../../../support/paste-utils'; +import { testLog } from '../../../support/test-helpers'; describe('Paste Heading Tests', () => { it('should paste all heading formats correctly', () => { diff --git a/cypress/e2e/page-paste/paste-lists.cy.ts b/cypress/e2e/page-paste/paste-lists.cy.ts index 445a83a4..b609e13a 100644 --- a/cypress/e2e/page-paste/paste-lists.cy.ts +++ b/cypress/e2e/page-paste/paste-lists.cy.ts @@ -1,5 +1,5 @@ -import { createTestPage, pasteContent } from '../../support/paste-utils'; -import { testLog } from '../../support/test-helpers'; +import { createTestPage, pasteContent } from '../../../support/paste-utils'; +import { testLog } from '../../../support/test-helpers'; describe('Paste List Tests', () => { it('should paste all list formats correctly', () => { @@ -214,12 +214,12 @@ Please let us know your feedback.`; cy.contains('Project Launch').should('exist'); cy.contains('We are excited to announce').should('exist'); - + // Verify special bullets are converted to BulletedListBlock cy.get('[data-block-type="bulleted_list"]').should('contain', 'Fast performance'); cy.get('[data-block-type="bulleted_list"]').should('contain', 'Secure encryption'); cy.get('[data-block-type="bulleted_list"]').should('contain', 'Offline mode'); - + testLog.info('✓ Generic text with special bullets pasted successfully'); // Exit list mode @@ -249,7 +249,7 @@ Please let us know your feedback.`; cy.get('[data-block-type="bulleted_list"]').contains('Private').should('exist'); cy.get('[data-block-type="bulleted_list"]').contains('Customizable').should('exist'); cy.get('[data-block-type="bulleted_list"]').contains('Self-hostable').should('exist'); - + testLog.info('✓ HTML list with inner newlines pasted successfully'); } }); diff --git a/cypress/e2e/page-paste/paste-plain-text.cy.ts b/cypress/e2e/page-paste/paste-plain-text.cy.ts index af942c23..7df2d590 100644 --- a/cypress/e2e/page-paste/paste-plain-text.cy.ts +++ b/cypress/e2e/page-paste/paste-plain-text.cy.ts @@ -1,5 +1,5 @@ -import { createTestPage, pasteContent } from '../../support/paste-utils'; -import { testLog } from '../../support/test-helpers'; +import { createTestPage, pasteContent } from '../../../support/paste-utils'; +import { testLog } from '../../../support/test-helpers'; describe('Paste Plain Text Tests', () => { it('should paste all plain text formats correctly', () => { diff --git a/cypress/e2e/page-paste/paste-tables.cy.ts b/cypress/e2e/page-paste/paste-tables.cy.ts index ae282671..481a29a2 100644 --- a/cypress/e2e/page-paste/paste-tables.cy.ts +++ b/cypress/e2e/page-paste/paste-tables.cy.ts @@ -1,5 +1,5 @@ -import { createTestPage, pasteContent } from '../../support/paste-utils'; -import { testLog } from '../../support/test-helpers'; +import { createTestPage, pasteContent } from '../../../support/paste-utils'; +import { testLog } from '../../../support/test-helpers'; describe('Paste Table Tests', () => { it('should paste all table formats correctly', () => { diff --git a/package.json b/package.json index 7c200aa6..67481f89 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 8115d8f4..5229f1aa 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 3ee8c0ca..8108901d 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/plugins/withHistory.ts b/src/application/slate-yjs/plugins/withHistory.ts index 8294fffa..c90d533e 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 d41aca87..7224e181 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 e6d3b832..924b38fe 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 64d73c4f..0734b76b 100644 --- a/src/application/types.ts +++ b/src/application/types.ts @@ -729,6 +729,8 @@ export enum CollabOrigin { Local = 'local', // from remote changes and never sync to remote. Remote = 'remote', + // from local changes manually applied to Yjs + LocalManual = 'local_manual', } export interface PublishViewPayload { diff --git a/src/components/app/hooks/useViewNavigation.ts b/src/components/app/hooks/useViewNavigation.ts index a7a0af08..4f5759f6 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 63cd04b2..f641a35f 100644 --- a/src/components/app/hooks/useViewSync.ts +++ b/src/components/app/hooks/useViewSync.ts @@ -1,6 +1,8 @@ -import { YDatabaseView } from '@/application/types'; import { useCallback } from 'react'; import * as Y from 'yjs'; + +import { YDatabaseView } from '@/application/types'; + import { SYNC_MAX_ATTEMPTS, SYNC_POLL_INTERVAL } from './constants'; export const useDatabaseViewSync = (views: Y.Map | undefined) => { diff --git a/src/components/as-template/icons.tsx b/src/components/as-template/icons.tsx index 7682489a..3facda5a 100644 --- a/src/components/as-template/icons.tsx +++ b/src/components/as-template/icons.tsx @@ -8,16 +8,16 @@ import { ReactComponent as Engineering } from '@/assets/icons/engineering.svg'; import { ReactComponent as Facebook } from '@/assets/icons/facebook.svg'; import { ReactComponent as GraduationCap } from '@/assets/icons/graduation_cap.svg'; import { ReactComponent as Instagram } from '@/assets/icons/instagram.svg'; -import { ReactComponent as Twitter } from '@/assets/icons/twitter.svg'; -import { ReactComponent as Tiktok } from '@/assets/icons/tiktok.svg'; import { ReactComponent as LinkedInIcon } from '@/assets/icons/linkedin.svg'; -import { ReactComponent as Startup } from '@/assets/icons/startup.svg'; -import { ReactComponent as User } from '@/assets/icons/user.svg'; -import { ReactComponent as UsersThree } from '@/assets/icons/users.svg'; import { ReactComponent as Management } from '@/assets/icons/management.svg'; import { ReactComponent as Marketing } from '@/assets/icons/marketing.svg'; -import { ReactComponent as Sales } from '@/assets/icons/sales.svg'; import { ReactComponent as Doc } from '@/assets/icons/page.svg'; +import { ReactComponent as Sales } from '@/assets/icons/sales.svg'; +import { ReactComponent as Startup } from '@/assets/icons/startup.svg'; +import { ReactComponent as Tiktok } from '@/assets/icons/tiktok.svg'; +import { ReactComponent as Twitter } from '@/assets/icons/twitter.svg'; +import { ReactComponent as User } from '@/assets/icons/user.svg'; +import { ReactComponent as UsersThree } from '@/assets/icons/users.svg'; import { ReactComponent as Wiki } from '@/assets/icons/wiki.svg'; import { ReactComponent as Youtube } from '@/assets/icons/youtube.svg'; diff --git a/src/components/chat/components/ai-writer/view-tree/index.tsx b/src/components/chat/components/ai-writer/view-tree/index.tsx index c817d956..9c86afa5 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 f3e3a9d7..ed8e266e 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 212ae24d..98a97f3e 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 a86a14eb..938b92e4 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/components/grid/grid-table/useGridDnd.ts b/src/components/database/components/grid/grid-table/useGridDnd.ts index 64529204..10e6d6c9 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/tabs/DatabaseTabItem.tsx b/src/components/database/components/tabs/DatabaseTabItem.tsx index 5fa6290f..838aea8b 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 1aa1f22d..83782e81 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/editor/Editable.tsx b/src/components/editor/Editable.tsx index fba23a35..392de014 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/drag-drop/handleBlockDrop.ts b/src/components/editor/components/drag-drop/handleBlockDrop.ts new file mode 100644 index 00000000..31fb58a7 --- /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 00000000..8014c485 --- /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 00000000..ee1d8b47 --- /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 00000000..e8f45eb9 --- /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 5e8568d1..6c1ab535 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/mention-panel/MentionPanel.tsx b/src/components/editor/components/panels/mention-panel/MentionPanel.tsx index 656cae41..4550c619 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/toolbar/block-controls/ControlActions.tsx b/src/components/editor/components/toolbar/block-controls/ControlActions.tsx index e479a846..d284799d 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 d838d492..018d6d38 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 0580db62..2fdab978 100644 --- a/src/components/editor/editor.scss +++ b/src/components/editor/editor.scss @@ -18,6 +18,15 @@ } +.block-element--dragging { + @apply opacity-50; + cursor: grabbing; +} + +.block-element.block-drop-target { + @apply bg-fill-list-hover; +} + .block-element[data-block-type="table/cell"] { .block-element .text-placeholder { @apply hidden; @@ -538,4 +547,4 @@ span[data-slate-placeholder="true"]:not(.inline-block-content) { } -} \ No newline at end of file +} diff --git a/src/utils/download.ts b/src/utils/download.ts index b78d8e98..98d0b2b8 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';