snapshot of https://github.com/ueberdosis/tiptap.git (main, 2026-09-15; blobs > 131072B omitted)
Some checks failed
Verify / Install node dependencies (push) Failing after 2s
Verify / Build packages (push) Has been skipped
Verify / Check linting & formatting (push) Has been skipped
Verify / Run unit tests (push) Has been skipped
Verify / Run e2e tests (shard 1/4) (push) Has been skipped
Verify / Run e2e tests (shard 2/4) (push) Has been skipped
Verify / Run e2e tests (shard 3/4) (push) Has been skipped
Verify / Run e2e tests (shard 4/4) (push) Has been skipped
Publish / Resolve publish config (push) Failing after 2s
Publish / Send notification to Slack (push) Has been skipped
Publish / Send failure notification to Slack (push) Failing after 8s
Verify / Merge e2e reports (push) Has been skipped
Publish / Build (push) Has been skipped
Publish / Bump Versions OR Release to Registry (push) Has been skipped
Some checks failed
Verify / Install node dependencies (push) Failing after 2s
Verify / Build packages (push) Has been skipped
Verify / Check linting & formatting (push) Has been skipped
Verify / Run unit tests (push) Has been skipped
Verify / Run e2e tests (shard 1/4) (push) Has been skipped
Verify / Run e2e tests (shard 2/4) (push) Has been skipped
Verify / Run e2e tests (shard 3/4) (push) Has been skipped
Verify / Run e2e tests (shard 4/4) (push) Has been skipped
Publish / Resolve publish config (push) Failing after 2s
Publish / Send notification to Slack (push) Has been skipped
Publish / Send failure notification to Slack (push) Failing after 8s
Verify / Merge e2e reports (push) Has been skipped
Publish / Build (push) Has been skipped
Publish / Bump Versions OR Release to Registry (push) Has been skipped
This commit is contained in:
1153
packages/extension-drag-handle-react/CHANGELOG.md
Normal file
1153
packages/extension-drag-handle-react/CHANGELOG.md
Normal file
File diff suppressed because it is too large
Load Diff
18
packages/extension-drag-handle-react/README.md
Normal file
18
packages/extension-drag-handle-react/README.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# @tiptap/extension-drag-handle-react
|
||||
|
||||
[](https://www.npmjs.com/package/@tiptap/extension-drag-handle-react)
|
||||
[](https://npmcharts.com/compare/tiptap?minimal=true)
|
||||
[](https://www.npmjs.com/package/@tiptap/extension-drag-handle-react)
|
||||
[](https://github.com/sponsors/ueberdosis)
|
||||
|
||||
## Introduction
|
||||
|
||||
Tiptap is a headless wrapper around [ProseMirror](https://ProseMirror.net) – a toolkit for building rich text WYSIWYG editors, which is already in use at many well-known companies such as _New York Times_, _The Guardian_ or _Atlassian_.
|
||||
|
||||
## Official Documentation
|
||||
|
||||
Documentation can be found on the [Tiptap website](https://tiptap.dev).
|
||||
|
||||
## License
|
||||
|
||||
Tiptap is open sourced software licensed under the [MIT license](https://github.com/ueberdosis/tiptap/blob/main/LICENSE.md).
|
||||
60
packages/extension-drag-handle-react/package.json
Normal file
60
packages/extension-drag-handle-react/package.json
Normal file
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"name": "@tiptap/extension-drag-handle-react",
|
||||
"version": "3.30.3",
|
||||
"description": "drag handle extension for tiptap with react",
|
||||
"keywords": [
|
||||
"tiptap",
|
||||
"tiptap extension"
|
||||
],
|
||||
"homepage": "https://tiptap.dev",
|
||||
"bugs": {
|
||||
"url": "https://github.com/ueberdosis/tiptap/issues"
|
||||
},
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/ueberdosis/tiptap",
|
||||
"directory": "packages/extension-drag-handle-react"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"files": [
|
||||
"src",
|
||||
"dist"
|
||||
],
|
||||
"type": "module",
|
||||
"main": "dist/index.cjs",
|
||||
"module": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": {
|
||||
"import": "./dist/index.d.ts",
|
||||
"require": "./dist/index.d.cts"
|
||||
},
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.cjs"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vp pack"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tiptap/extension-drag-handle": "workspace:^",
|
||||
"@tiptap/pm": "workspace:^",
|
||||
"@tiptap/react": "workspace:^",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extension-drag-handle": "workspace:*",
|
||||
"@tiptap/pm": "workspace:*",
|
||||
"@tiptap/react": "workspace:*",
|
||||
"react": "^16.8 || ^17 || ^18 || ^19",
|
||||
"react-dom": "^16.8 || ^17 || ^18 || ^19"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { Editor } from '@tiptap/core'
|
||||
import Document from '@tiptap/extension-document'
|
||||
import Paragraph from '@tiptap/extension-paragraph'
|
||||
import Text from '@tiptap/extension-text'
|
||||
import { useEditorState } from '@tiptap/react'
|
||||
import { act, render } from '@testing-library/react'
|
||||
import React from 'react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vite-plus/test'
|
||||
|
||||
import { DragHandle } from './DragHandle.js'
|
||||
|
||||
function createRealEditor() {
|
||||
const element = document.createElement('div')
|
||||
document.body.appendChild(element)
|
||||
|
||||
return new Editor({
|
||||
element,
|
||||
extensions: [Document, Paragraph, Text],
|
||||
content: '<p>hello world</p>',
|
||||
})
|
||||
}
|
||||
|
||||
describe('DragHandle plugin registration stability', () => {
|
||||
let editor: Editor
|
||||
|
||||
afterEach(() => {
|
||||
editor?.destroy()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
// Re-registering mid-drag tears the handle out of the DOM and breaks dragging.
|
||||
it('does not re-register the plugin on transactions when onNodeChange is an unstable inline callback', () => {
|
||||
editor = createRealEditor()
|
||||
const register = vi.spyOn(editor, 'registerPlugin')
|
||||
const unregister = vi.spyOn(editor, 'unregisterPlugin')
|
||||
|
||||
function Parent({ editor: ed }: { editor: Editor }) {
|
||||
// Re-render on every transaction, like real apps using useEditor/useEditorState.
|
||||
useEditorState({ editor: ed, selector: s => s.transactionNumber })
|
||||
return React.createElement(DragHandle, {
|
||||
editor: ed,
|
||||
// A fresh arrow function on every render (the reporter's code).
|
||||
onNodeChange: () => {},
|
||||
children: React.createElement('span', null, 'handle'),
|
||||
} as never)
|
||||
}
|
||||
|
||||
act(() => {
|
||||
render(React.createElement(Parent, { editor }))
|
||||
})
|
||||
|
||||
const registersAfterMount = register.mock.calls.length
|
||||
expect(registersAfterMount).toBe(1)
|
||||
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
act(() => {
|
||||
editor.view.dispatch(editor.state.tr.insertText('x', 1))
|
||||
})
|
||||
}
|
||||
|
||||
expect(register.mock.calls.length).toBe(1)
|
||||
expect(unregister.mock.calls.length).toBe(0)
|
||||
})
|
||||
|
||||
// The ref must forward to the newest callback, not the one from the first render.
|
||||
it('invokes the most recent onNodeChange after a re-render', () => {
|
||||
editor = createRealEditor()
|
||||
const calls: Array<{ tag: string; pos: number }> = []
|
||||
let bumpTag: (() => void) | undefined
|
||||
|
||||
function Parent() {
|
||||
const [tag, setTag] = React.useState('first')
|
||||
bumpTag = () => setTag('latest')
|
||||
return React.createElement(DragHandle, {
|
||||
editor,
|
||||
onNodeChange: (data: { pos: number }) => calls.push({ tag, pos: data.pos }),
|
||||
children: React.createElement('span', null, 'handle'),
|
||||
} as never)
|
||||
}
|
||||
|
||||
act(() => {
|
||||
render(React.createElement(Parent))
|
||||
})
|
||||
|
||||
// Re-render with a new callback identity, then fire the hide event.
|
||||
act(() => {
|
||||
bumpTag?.()
|
||||
})
|
||||
// `hideDragHandle` meta triggers onNodeChange(null, -1) in the plugin.
|
||||
act(() => {
|
||||
editor.view.dispatch(editor.state.tr.setMeta('hideDragHandle', true))
|
||||
})
|
||||
|
||||
expect(calls.at(-1)).toEqual({ tag: 'latest', pos: -1 })
|
||||
})
|
||||
})
|
||||
105
packages/extension-drag-handle-react/src/DragHandle.test.ts
Normal file
105
packages/extension-drag-handle-react/src/DragHandle.test.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { render } from '@testing-library/react'
|
||||
import React from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'
|
||||
|
||||
import { DragHandle } from './DragHandle.js'
|
||||
|
||||
const { dragHandlePluginMock, unbindMock } = vi.hoisted(() => ({
|
||||
dragHandlePluginMock: vi.fn(() => ({
|
||||
plugin: { spec: { key: 'dragHandle' } },
|
||||
unbind: unbindMock,
|
||||
})),
|
||||
unbindMock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@tiptap/extension-drag-handle', () => ({
|
||||
DragHandlePlugin: dragHandlePluginMock,
|
||||
dragHandlePluginDefaultKey: 'dragHandle',
|
||||
defaultComputePositionConfig: {},
|
||||
normalizeNestedOptions: (value: unknown) => value,
|
||||
}))
|
||||
|
||||
function createEditor() {
|
||||
return {
|
||||
isDestroyed: false,
|
||||
registerPlugin: vi.fn(),
|
||||
unregisterPlugin: vi.fn(),
|
||||
}
|
||||
}
|
||||
|
||||
describe('DragHandle', () => {
|
||||
beforeEach(() => {
|
||||
dragHandlePluginMock.mockClear()
|
||||
unbindMock.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
it('survives the React 19 strict-mode double mount with a stable portal element', () => {
|
||||
const editor = createEditor()
|
||||
|
||||
let result: ReturnType<typeof render> | undefined
|
||||
const renderInStrictMode = () => {
|
||||
result = render(
|
||||
React.createElement(
|
||||
React.StrictMode,
|
||||
null,
|
||||
React.createElement(DragHandle, {
|
||||
editor: editor as never,
|
||||
children: React.createElement('button', { type: 'button' }, 'Drag'),
|
||||
} as never),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
expect(renderInStrictMode).not.toThrow()
|
||||
|
||||
// StrictMode mounts, tears down, then remounts the effect in development.
|
||||
// Every setup pass must reuse the same element instance created via useRef.
|
||||
expect(dragHandlePluginMock.mock.calls.length).toBeGreaterThan(1)
|
||||
const elements = dragHandlePluginMock.mock.calls.map(([options]) => options.element)
|
||||
const [firstElement] = elements
|
||||
|
||||
expect(firstElement).toBeInstanceOf(HTMLDivElement)
|
||||
expect(elements.every(element => element === firstElement)).toBe(true)
|
||||
expect(unbindMock).toHaveBeenCalled()
|
||||
expect(firstElement.textContent).toContain('Drag')
|
||||
|
||||
result?.unmount()
|
||||
|
||||
expect(editor.unregisterPlugin).toHaveBeenCalledWith('dragHandle')
|
||||
expect(unbindMock).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('updates className without re-registering the plugin', () => {
|
||||
const editor = createEditor()
|
||||
|
||||
const { rerender } = render(
|
||||
React.createElement(DragHandle, {
|
||||
editor: editor as never,
|
||||
className: 'drag-handle',
|
||||
children: React.createElement('span', null, 'Handle'),
|
||||
} as never),
|
||||
)
|
||||
|
||||
const registerCount = editor.registerPlugin.mock.calls.length
|
||||
const unregisterCount = editor.unregisterPlugin.mock.calls.length
|
||||
const element = dragHandlePluginMock.mock.calls[0][0].element
|
||||
|
||||
expect(element.className).toBe('drag-handle')
|
||||
|
||||
rerender(
|
||||
React.createElement(DragHandle, {
|
||||
editor: editor as never,
|
||||
className: 'drag-handle-updated',
|
||||
children: React.createElement('span', null, 'Handle'),
|
||||
} as never),
|
||||
)
|
||||
|
||||
expect(element.className).toBe('drag-handle-updated')
|
||||
expect(editor.registerPlugin.mock.calls.length).toBe(registerCount)
|
||||
expect(editor.unregisterPlugin.mock.calls.length).toBe(unregisterCount)
|
||||
})
|
||||
})
|
||||
159
packages/extension-drag-handle-react/src/DragHandle.tsx
Normal file
159
packages/extension-drag-handle-react/src/DragHandle.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import {
|
||||
type DragHandlePluginProps,
|
||||
type NestedOptions,
|
||||
defaultComputePositionConfig,
|
||||
DragHandlePlugin,
|
||||
dragHandlePluginDefaultKey,
|
||||
normalizeNestedOptions,
|
||||
} from '@tiptap/extension-drag-handle'
|
||||
import type { Node as PMNode } from '@tiptap/pm/model'
|
||||
import type { Editor } from '@tiptap/react'
|
||||
import { type ReactNode, useEffect, useMemo, useRef } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
type Optional<T, K extends keyof T> = Pick<Partial<T>, K> & Omit<T, K>
|
||||
|
||||
export type DragHandleProps = Omit<
|
||||
Optional<DragHandlePluginProps, 'pluginKey'>,
|
||||
'element' | 'nestedOptions'
|
||||
> & {
|
||||
className?: string
|
||||
onNodeChange?: (data: { node: PMNode | null; editor: Editor; pos: number }) => void
|
||||
children: ReactNode
|
||||
|
||||
/**
|
||||
* Enable drag handles for nested content (list items, blockquotes, etc.).
|
||||
*
|
||||
* When enabled, the drag handle will appear for nested blocks, not just
|
||||
* top-level blocks. A rule-based scoring system determines which node
|
||||
* to target based on cursor position and configured rules.
|
||||
*
|
||||
* @default false
|
||||
*
|
||||
* @example
|
||||
* // Simple enable with sensible defaults
|
||||
* <DragHandle editor={editor} nested>
|
||||
* <GripIcon />
|
||||
* </DragHandle>
|
||||
*
|
||||
* @example
|
||||
* // With custom configuration
|
||||
* <DragHandle
|
||||
* editor={editor}
|
||||
* nested={{
|
||||
* edgeDetection: 'left',
|
||||
* allowedContainers: ['bulletList', 'orderedList'],
|
||||
* }}
|
||||
* >
|
||||
* <GripIcon />
|
||||
* </DragHandle>
|
||||
*
|
||||
* @example
|
||||
* // With custom rules
|
||||
* <DragHandle
|
||||
* editor={editor}
|
||||
* nested={{
|
||||
* rules: [{
|
||||
* id: 'excludeCodeBlocks',
|
||||
* evaluate: ({ node }) => node.type.name === 'codeBlock' ? 1000 : 0,
|
||||
* }],
|
||||
* }}
|
||||
* >
|
||||
* <GripIcon />
|
||||
* </DragHandle>
|
||||
*/
|
||||
nested?: boolean | NestedOptions
|
||||
}
|
||||
|
||||
export const DragHandle = (props: DragHandleProps) => {
|
||||
const {
|
||||
className = 'drag-handle',
|
||||
children,
|
||||
editor,
|
||||
pluginKey = dragHandlePluginDefaultKey,
|
||||
onNodeChange,
|
||||
onElementDragStart,
|
||||
onElementDragEnd,
|
||||
getReferencedVirtualElement,
|
||||
computePositionConfig = defaultComputePositionConfig,
|
||||
nested = false,
|
||||
} = props
|
||||
const elementRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
if (elementRef.current === null && typeof document !== 'undefined') {
|
||||
elementRef.current = document.createElement('div')
|
||||
}
|
||||
|
||||
// Keep callbacks in a ref so an unstable identity can't re-register the plugin.
|
||||
const callbacks = {
|
||||
onNodeChange,
|
||||
onElementDragStart,
|
||||
onElementDragEnd,
|
||||
getReferencedVirtualElement,
|
||||
}
|
||||
const callbacksRef = useRef(callbacks)
|
||||
callbacksRef.current = callbacks
|
||||
|
||||
// oxlint-disable-next-line react-hooks/exhaustive-deps
|
||||
const nestedOptions = useMemo(() => normalizeNestedOptions(nested), [JSON.stringify(nested)])
|
||||
|
||||
useEffect(() => {
|
||||
const element = elementRef.current
|
||||
if (!element) {
|
||||
return
|
||||
}
|
||||
|
||||
element.className = className
|
||||
}, [className])
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
if (editor.isDestroyed) {
|
||||
return
|
||||
}
|
||||
|
||||
const element = elementRef.current
|
||||
if (!element) {
|
||||
return
|
||||
}
|
||||
|
||||
element.style.visibility = 'hidden'
|
||||
element.style.position = 'absolute'
|
||||
element.dataset.dragging = 'false'
|
||||
|
||||
const { plugin, unbind } = DragHandlePlugin({
|
||||
editor,
|
||||
element,
|
||||
pluginKey,
|
||||
computePositionConfig: {
|
||||
...defaultComputePositionConfig,
|
||||
...computePositionConfig,
|
||||
},
|
||||
onElementDragStart: event => callbacksRef.current.onElementDragStart?.(event),
|
||||
onElementDragEnd: event => callbacksRef.current.onElementDragEnd?.(event),
|
||||
onNodeChange: data => callbacksRef.current.onNodeChange?.(data),
|
||||
getReferencedVirtualElement: () =>
|
||||
callbacksRef.current.getReferencedVirtualElement?.() ?? null,
|
||||
nestedOptions,
|
||||
})
|
||||
|
||||
editor.registerPlugin(plugin)
|
||||
|
||||
return () => {
|
||||
if (!editor.isDestroyed) {
|
||||
editor.unregisterPlugin(pluginKey)
|
||||
}
|
||||
unbind()
|
||||
}
|
||||
}, [editor, pluginKey, computePositionConfig, nestedOptions])
|
||||
|
||||
const element = elementRef.current
|
||||
if (!element) {
|
||||
return null
|
||||
}
|
||||
|
||||
return createPortal(children, element)
|
||||
}
|
||||
5
packages/extension-drag-handle-react/src/index.ts
Normal file
5
packages/extension-drag-handle-react/src/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { DragHandle } from './DragHandle.js'
|
||||
|
||||
export * from './DragHandle.js'
|
||||
|
||||
export default DragHandle
|
||||
9
packages/extension-drag-handle-react/vite.config.ts
Normal file
9
packages/extension-drag-handle-react/vite.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vite-plus'
|
||||
import { basePackConfig } from '../../pack.config.mjs'
|
||||
|
||||
export default defineConfig({
|
||||
pack: {
|
||||
entry: ['src/index.ts'],
|
||||
...basePackConfig(),
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user