Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*",
Expand Down
189 changes: 189 additions & 0 deletions apps/desktop/src/editor/block-drag-clipboard.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof render> {
return render(
<div className="meowdown">
<div
ref={(element) => {
if (element !== null) {
editor.view.dom = element
}
}}
/>
{createElement('prosekit-block-handle-draggable', {
'data-testid': 'block-handle',
})}
<div draggable data-testid="other-drag-source" />
<BlockDragClipboard />
</div>,
)
}

function dataTransfer(): DataTransfer {
const values = new Map<string, string>()
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 =
'<ul data-pm-slice="0 0 []"><li data-list-kind="task"><p>Task</p></li></ul>'
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)
})
})
123 changes: 123 additions & 0 deletions apps/desktop/src/editor/block-drag-clipboard.tsx
Original file line number Diff line number Diff line change
@@ -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
}
2 changes: 2 additions & 0 deletions apps/desktop/src/editor/note-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -425,6 +426,7 @@ export function NoteEditor({
onExitBoundary={handleExitBoundary}
>
<EditorInputTraits />
<BlockDragClipboard />
<FormattingToolbarBridge />
{children}
</MeowdownEditor>
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading