diff --git a/.changeset/drag-event-guard.md b/.changeset/drag-event-guard.md new file mode 100644 index 0000000000..d90c7a9649 --- /dev/null +++ b/.changeset/drag-event-guard.md @@ -0,0 +1,7 @@ +--- +'@portabletext/editor': patch +--- + +fix: guard `drag` and `dragleave` handlers without resolving an event position + +The editor no longer runs a caret hit-test and block rect reads for every `drag` and `dragleave` event during a drag. Both events forward to behaviors without a position, so the resolution was pure per-pointer-move cost; the handlers now only check that the event target belongs to the editor. In rare cases where position resolution would have failed (for example while the editor is tearing down mid-drag), the `drag.drag` and `drag.dragleave` behavior events now still fire. diff --git a/packages/editor/src/editor/Editable.tsx b/packages/editor/src/editor/Editable.tsx index d0d0954271..afa5df55d9 100644 --- a/packages/editor/src/editor/Editable.tsx +++ b/packages/editor/src/editor/Editable.tsx @@ -687,13 +687,14 @@ export const PortableTextEditable = forwardRef< return } - const position = getEventPosition({ - editorActor, - editorEngine, - event: event.nativeEvent, - }) - - if (!position) { + // The forwarded `drag.drag` event carries no position, so resolving + // one (a caret hit-test plus block rect reads) is wasted work on an + // event the browser fires continuously on the drag source. A + // containment check is all the guard needs. + if ( + editorActor.getSnapshot().matches({setup: 'setting up'}) || + !DOMEditor.hasTarget(editorEngine, event.target) + ) { return } @@ -860,13 +861,13 @@ export const PortableTextEditable = forwardRef< return } - const position = getEventPosition({ - editorActor, - editorEngine, - event: event.nativeEvent, - }) - - if (!position) { + // The forwarded `drag.dragleave` event carries no position either; + // the same cheap guard as `drag.drag` applies. `dragleave` fires on + // every element boundary inside the editor during a drag. + if ( + editorActor.getSnapshot().matches({setup: 'setting up'}) || + !DOMEditor.hasTarget(editorEngine, event.target) + ) { return } diff --git a/packages/editor/tests/event.drag.test.tsx b/packages/editor/tests/event.drag.test.tsx new file mode 100644 index 0000000000..57c491c8bd --- /dev/null +++ b/packages/editor/tests/event.drag.test.tsx @@ -0,0 +1,69 @@ +import {describe, expect, test, vi} from 'vitest' +import {defineBehavior} from '../src/behaviors/behavior.types.behavior' +import {BehaviorPlugin} from '../src/plugins/plugin.behavior' +import {createTestEditor} from '../src/test/vitest' + +describe('event.drag', () => { + test('Scenario: `drag.drag` and `drag.dragleave` reach behaviors without a caret hit-test', async () => { + const onDrag = vi.fn() + const onDragLeave = vi.fn() + + const {locator} = await createTestEditor({ + children: ( + { + onDrag() + return false + }, + actions: [], + }), + defineBehavior({ + on: 'drag.dragleave', + guard: () => { + onDragLeave() + return false + }, + actions: [], + }), + ]} + /> + ), + }) + + const editorElement = locator.element() + + const documentWithCaret = window.document as Document & { + caretPositionFromPoint(x: number, y: number): unknown + } + const caretHitTest = vi.spyOn(documentWithCaret, 'caretPositionFromPoint') + + editorElement.dispatchEvent( + new DragEvent('drag', { + bubbles: true, + cancelable: true, + dataTransfer: new DataTransfer(), + }), + ) + editorElement.dispatchEvent( + new DragEvent('dragleave', { + bubbles: true, + cancelable: true, + dataTransfer: new DataTransfer(), + }), + ) + + await vi.waitFor(() => { + expect(onDrag).toHaveBeenCalledTimes(1) + expect(onDragLeave).toHaveBeenCalledTimes(1) + }) + + // These events carry no position, so the handlers must not pay for + // resolving one at pointer-move frequency. + expect(caretHitTest).not.toHaveBeenCalled() + + caretHitTest.mockRestore() + }) +})