diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 2193e3ed..c037cfc3 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -25,6 +25,7 @@ "@dnd-kit/utilities": "^3.2.2", "@meowdown/core": "^0.42.0", "@meowdown/react": "^0.42.0", + "@prosekit/pm": "^0.1.19-beta.2", "@reflect/core": "workspace:*", "@reflect/db": "workspace:*", "@reflect/design-system": "workspace:*", diff --git a/apps/desktop/src/editor/block-drag-clipboard.test.tsx b/apps/desktop/src/editor/block-drag-clipboard.test.tsx new file mode 100644 index 00000000..f0418525 --- /dev/null +++ b/apps/desktop/src/editor/block-drag-clipboard.test.tsx @@ -0,0 +1,189 @@ +import { createElement } from 'react' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import '@testing-library/jest-dom/vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { docToMarkdown, markdownToDoc } from '@meowdown/core' +import { DOMParser, Fragment, Slice, type Schema } from '@prosekit/pm/model' +import { BlockDragClipboard } from './block-drag-clipboard' + +interface DraggingStub { + readonly slice: Slice + readonly move: boolean + readonly node: object +} + +const editor = vi.hoisted(() => ({ + mounted: true, + view: { + dom: document.createElement('div'), + dragging: null as DraggingStub | null, + serializeForClipboard: vi.fn(), + state: { schema: null as Schema | null }, + }, +})) + +vi.mock('@meowdown/react', () => ({ + useEditor: () => editor, +})) + +function renderBridge(): ReturnType { + return render( +
+
{ + if (element !== null) { + editor.view.dom = element + } + }} + /> + {createElement('prosekit-block-handle-draggable', { + 'data-testid': 'block-handle', + })} +
+ +
, + ) +} + +function dataTransfer(): DataTransfer { + const values = new Map() + return { + clearData: vi.fn((type?: string) => { + if (type === undefined) { + values.clear() + } else { + values.delete(type) + } + }), + getData: vi.fn((type: string) => values.get(type) ?? ''), + setData: vi.fn((type: string, value: string) => values.set(type, value)), + } as unknown as DataTransfer +} + +afterEach(() => { + cleanup() + editor.mounted = true + editor.view.dom = document.createElement('div') + editor.view.dragging = null + editor.view.state.schema = null + vi.restoreAllMocks() + vi.clearAllMocks() +}) + +describe('BlockDragClipboard', () => { + it('serializes a handled round task as lossless ProseMirror clipboard data', () => { + const sourceDocument = markdownToDoc('+ [ ] Task') + const sourceTask = sourceDocument.firstChild + expect(sourceTask).not.toBeNull() + const sourceSlice = new Slice(Fragment.from(sourceTask), 0, 0) + const serializedSlice = new Slice(sourceSlice.content, 0, 0) + const selectedNode = { from: 3, to: 29 } + const container = document.createElement('div') + container.innerHTML = + '
  • Task

' + editor.view.state.schema = sourceDocument.type.schema + editor.view.dragging = { slice: sourceSlice, move: true, node: selectedNode } + editor.view.serializeForClipboard.mockReturnValue({ + dom: container, + text: '+ [ ] Task', + slice: serializedSlice, + }) + const transfer = dataTransfer() + + renderBridge() + fireEvent.dragStart(screen.getByTestId('block-handle'), { dataTransfer: transfer }) + + expect(editor.view.serializeForClipboard).toHaveBeenCalledWith(sourceSlice) + const html = transfer.getData('text/html') + const transferred = document.createElement('div') + transferred.innerHTML = html + expect(transferred.firstElementChild).toHaveAttribute('data-pm-slice', '0 0 []') + expect(transferred.firstElementChild).toHaveAttribute('data-list-kind', 'task') + expect(transferred.firstElementChild).toHaveAttribute('data-list-marker', '+') + expect(docToMarkdown(DOMParser.fromSchema(sourceDocument.type.schema).parse(transferred))).toBe( + '+ [ ] Task\n', + ) + expect(transfer.getData('text/plain')).toBe('+ [ ] Task') + expect(editor.view.dragging).toEqual({ + slice: serializedSlice, + move: true, + node: selectedNode, + }) + }) + + it('does not rewrite unrelated drag payloads', () => { + const unrelatedDragging = { + slice: Slice.empty, + move: true, + node: { from: 1, to: 2 }, + } + editor.view.dragging = unrelatedDragging + + renderBridge() + const otherSource = screen.getByTestId('other-drag-source') + fireEvent.dragStart(otherSource, { + dataTransfer: dataTransfer(), + }) + fireEvent.dragEnd(otherSource) + + expect(editor.view.serializeForClipboard).not.toHaveBeenCalled() + expect(editor.view.dragging).toBe(unrelatedDragging) + }) + + it('clears the source slice when the handled drag ends', () => { + editor.view.dragging = { + slice: Slice.empty, + move: true, + node: { from: 1, to: 2 }, + } + + renderBridge() + fireEvent.dragEnd(screen.getByTestId('block-handle')) + + expect(editor.view.dragging).toBeNull() + }) + + it('retries a delayed mount and removes its listeners on unmount', () => { + const retries: FrameRequestCallback[] = [] + const requestFrame = vi + .spyOn(window, 'requestAnimationFrame') + .mockImplementation((callback) => { + retries.push(callback) + return 1 + }) + editor.mounted = false + + const rendered = renderBridge() + const handle = screen.getByTestId('block-handle') + const wrapper = handle.closest('.meowdown') + if (wrapper === null) { + throw new Error('Expected the test handle inside a Meowdown wrapper') + } + expect(requestFrame).toHaveBeenCalledOnce() + + editor.mounted = true + const runRetry = retries[0] + if (runRetry === undefined) { + throw new Error('Expected the bridge to schedule a mount retry') + } + act(() => runRetry(0)) + editor.view.dragging = { + slice: Slice.empty, + move: true, + node: { from: 1, to: 2 }, + } + fireEvent.dragEnd(handle) + expect(editor.view.dragging).toBeNull() + + const draggingAfterUnmount = { + slice: Slice.empty, + move: true, + node: { from: 2, to: 3 }, + } + rendered.unmount() + editor.view.dragging = draggingAfterUnmount + wrapper.append(handle) + fireEvent.dragEnd(handle) + expect(editor.view.dragging).toBe(draggingAfterUnmount) + }) +}) diff --git a/apps/desktop/src/editor/block-drag-clipboard.tsx b/apps/desktop/src/editor/block-drag-clipboard.tsx new file mode 100644 index 00000000..9e817003 --- /dev/null +++ b/apps/desktop/src/editor/block-drag-clipboard.tsx @@ -0,0 +1,123 @@ +import { useEffect } from 'react' +import { useEditor } from '@meowdown/react' +import { DOMSerializer } from '@prosekit/pm/model' + +const BLOCK_HANDLE_SELECTOR = 'prosekit-block-handle-draggable' + +function isBlockHandleEvent(event: Event): boolean { + return ( + event.target instanceof Element && + event.target.closest(BLOCK_HANDLE_SELECTOR) !== null + ) +} + +function isDragEvent(event: Event): event is DragEvent { + return 'dataTransfer' in event +} + +/** + * Give block-handle drags a ProseMirror-native clipboard envelope. ProseKit's + * handle only supplies the rendered block HTML; that is enough + * inside one editor, where `view.dragging` carries the source slice, but a drop + * into another editor parses the HTML as foreign content. In the daily stream + * that turned task-list DOM into a literal `\[ ]` paragraph and detached task + * text. The serialized payload carries `data-pm-slice`, so another mounted note + * can reconstruct the original block without passing it through rich-HTML + * conversion. This only makes the transfer lossless; ProseMirror continues to + * treat drops between editor views as copies. + */ +export function BlockDragClipboard(): null { + const editor = useEditor() + + useEffect(() => { + let frame: number | null = null + let removeListener: (() => void) | null = null + + const attach = (): void => { + if (!editor.mounted) { + frame = requestAnimationFrame(attach) + return + } + + frame = null + const view = editor.view + const wrapper = view.dom.closest('.meowdown') + if (wrapper === null) { + return + } + + const handleDragStart = (event: Event): void => { + if (!isDragEvent(event) || event.dataTransfer === null) { + return + } + const dataTransfer = event.dataTransfer + if (!isBlockHandleEvent(event)) { + return + } + + // ProseKit's listener runs on the draggable handle before this bubbling + // listener, so it has already selected the block and populated + // `view.dragging` with the exact source slice. + const dragging = view.dragging + if (dragging === null) { + return + } + + const serialized = view.serializeForClipboard(dragging.slice) + const sliceMetadata = serialized.dom + .querySelector('[data-pm-slice]') + ?.getAttribute('data-pm-slice') + if (sliceMetadata === null || sliceMetadata === undefined) { + return + } + + // The flat-list clipboard serializer normalizes list markers, which + // would turn Reflect's round `+ [ ]` task into a plain `- [ ]` + // checklist. Serialize through the full Meowdown schema to retain its + // marker attributes, then reuse ProseMirror's own slice metadata. + const container = view.dom.ownerDocument.createElement('div') + container.append( + DOMSerializer.fromSchema(view.state.schema).serializeFragment( + serialized.slice.content, + { document: view.dom.ownerDocument }, + ), + ) + const firstElement = container.firstElementChild + if (firstElement === null) { + return + } + firstElement.setAttribute('data-pm-slice', sliceMetadata) + + dataTransfer.setData('text/html', container.innerHTML) + dataTransfer.setData('text/plain', serialized.text) + view.dragging = { ...dragging, slice: serialized.slice } + } + + const handleDragEnd = (event: Event): void => { + if (isBlockHandleEvent(event)) { + // The handle lives outside `view.dom`, so ProseMirror's own dragend + // handler never clears this source-view state after a cross-editor + // drop or a canceled drag. + view.dragging = null + } + } + + wrapper.addEventListener('dragstart', handleDragStart) + wrapper.addEventListener('dragend', handleDragEnd) + removeListener = () => { + wrapper.removeEventListener('dragstart', handleDragStart) + wrapper.removeEventListener('dragend', handleDragEnd) + } + } + + attach() + return () => { + if (frame !== null) { + cancelAnimationFrame(frame) + } + removeListener?.() + } + }, [editor]) + + return null +} diff --git a/apps/desktop/src/editor/note-editor.tsx b/apps/desktop/src/editor/note-editor.tsx index 98d99cde..d3067b8e 100644 --- a/apps/desktop/src/editor/note-editor.tsx +++ b/apps/desktop/src/editor/note-editor.tsx @@ -28,6 +28,7 @@ import { type WikilinkSearchHandler, } from '@meowdown/react' import { EditorInputTraits } from '@/editor/editor-input-traits' +import { BlockDragClipboard } from '@/editor/block-drag-clipboard' import { FormattingToolbarBridge } from '@/editor/formatting-toolbar-bridge' import { IMAGE_LIGHTBOX_TRANSITION_NAME, @@ -425,6 +426,7 @@ export function NoteEditor({ onExitBoundary={handleExitBoundary} > + {children} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ba064fc8..8dddd856 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -39,6 +39,9 @@ importers: '@meowdown/react': specifier: ^0.42.0 version: 0.42.0(@lezer/common@1.5.2)(@lezer/highlight@1.2.3)(@shikijs/types@4.3.1)(@types/hast@3.0.4)(@types/react@19.2.17)(date-fns@4.4.0)(prosemirror-model@1.25.9)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0)(prosemirror-view@1.42.0)(react-dom@19.2.7)(react@19.2.7) + '@prosekit/pm': + specifier: ^0.1.19-beta.2 + version: 0.1.19-beta.2 '@reflect/core': specifier: workspace:* version: link:../../packages/core