feat: can drag and drop hitl block

This commit is contained in:
Joel 2025-08-07 15:05:29 +08:00
parent 792f28451c
commit a9e6140dc6
6 changed files with 173 additions and 6 deletions

View File

@ -1,7 +1,7 @@
'use client'
import type { FC } from 'react'
import React, { useEffect } from 'react'
import React, { useEffect, useState } from 'react'
import type {
EditorState,
} from 'lexical'
@ -16,7 +16,8 @@ import { ContentEditable } from '@lexical/react/LexicalContentEditable'
import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary'
import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin'
import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin'
// import TreeView from './plugins/tree-view'
import DraggableBlockPlugin from './plugins/draggable-plugin'
import TreeView from './plugins/tree-view'
import Placeholder from './plugins/placeholder'
import ComponentPickerBlock from './plugins/component-picker-block'
import {
@ -165,9 +166,16 @@ const PromptEditor: FC<PromptEditorProps> = ({
} as any)
}, [eventEmitter, historyBlock?.history])
const [floatingAnchorElem, setFloatingAnchorElem] = useState(null)
const onRef = (_floatingAnchorElem: any) => {
if (_floatingAnchorElem !== null)
setFloatingAnchorElem(_floatingAnchorElem)
}
return (
<LexicalComposer initialConfig={{ ...initialConfig, editable }}>
<div className={cn('relative', wrapperClassName)}>
<div className={cn('relative', wrapperClassName)} ref={onRef}>
<RichTextPlugin
contentEditable={
<ContentEditable
@ -271,7 +279,10 @@ const PromptEditor: FC<PromptEditorProps> = ({
<OnBlurBlock onBlur={onBlur} onFocus={onFocus} />
<UpdateBlock instanceId={instanceId} />
<HistoryPlugin />
{/* <TreeView /> */}
{floatingAnchorElem && (
<DraggableBlockPlugin anchorElem={floatingAnchorElem} />
)}
<TreeView />
</div>
</LexicalComposer>
)

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" data-name="Layer 1" viewBox="0 0 24 24" fill="currentColor"><path stroke="currentColor" d="M8.5 10a2 2 0 1 0 2 2 2 2 0 0 0-2-2Zm0 7a2 2 0 1 0 2 2 2 2 0 0 0-2-2Zm7-10a2 2 0 1 0-2-2 2 2 0 0 0 2 2Zm-7-4a2 2 0 1 0 2 2 2 2 0 0 0-2-2Zm7 14a2 2 0 1 0 2 2 2 2 0 0 0-2-2Zm0-7a2 2 0 1 0 2 2 2 2 0 0 0-2-2Z"/></svg>

After

Width:  |  Height:  |  Size: 344 B

View File

@ -0,0 +1,40 @@
.draggable-block-menu {
border-radius: 4px;
padding: 2px 1px;
cursor: grab;
opacity: 0;
position: absolute;
left: -20px;
top: 0;
will-change: transform;
display: flex;
gap: 2px;
}
.draggable-block-menu .icon {
width: 16px;
height: 16px;
opacity: 0.3;
background-image: url(./icons/draggable-block-menu.svg);
background-repeat: no-repeat;
}
.draggable-block-menu:active {
cursor: grabbing;
}
.draggable-block-menu .icon:hover {
background-color: #efefef;
}
.draggable-block-target-line {
pointer-events: none;
background: deepskyblue;
height: 4px;
position: absolute;
left: -21px;
right: 0;
top: 0;
opacity: 0;
will-change: transform;
}

View File

@ -0,0 +1,70 @@
import type { JSX } from 'react'
import './index.css'
import { DraggableBlockPlugin_EXPERIMENTAL } from '@lexical/react/LexicalDraggableBlockPlugin'
import { useEffect, useRef, useState } from 'react'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
const DRAGGABLE_BLOCK_MENU_CLASSNAME = 'draggable-block-menu'
function isOnMenu(element: HTMLElement): boolean {
return !!element.closest(`.${DRAGGABLE_BLOCK_MENU_CLASSNAME}`)
}
const SUPPORT_DRAG_CLASS = 'support-drag'
function checkSupportDrag(element: Element | null): boolean {
if (!element) return false
if (element.classList.contains(SUPPORT_DRAG_CLASS)) return true
if (element.querySelector(`.${SUPPORT_DRAG_CLASS}`)) return true
return !!(element.closest(`.${SUPPORT_DRAG_CLASS}`))
}
export default function DraggableBlockPlugin({
anchorElem = document.body,
}: {
anchorElem?: HTMLElement;
}): JSX.Element {
const menuRef = useRef<HTMLDivElement>(null)
const targetLineRef = useRef<HTMLDivElement>(null)
const [draggableElement, setDraggableElement] = useState<HTMLElement | null>(
null,
)
const [editor] = useLexicalComposerContext()
const [isSupportDrag, setIsSupportDrag] = useState(false)
useEffect(() => {
const root = editor.getRootElement()
if (!root) return
const onMove = (e: MouseEvent) => {
const isSupportDrag = checkSupportDrag(e.target as Element)
setIsSupportDrag(isSupportDrag)
}
root.addEventListener('mousemove', onMove)
return () => root.removeEventListener('mousemove', onMove)
}, [editor])
return (
<DraggableBlockPlugin_EXPERIMENTAL
anchorElem={anchorElem}
menuRef={menuRef as any}
targetLineRef={targetLineRef as any}
menuComponent={
isSupportDrag ? <div ref={menuRef} className="icon draggable-block-menu">
<div className="icon" />
</div> : null
}
targetLineComponent={
<div ref={targetLineRef} className="draggable-block-target-line" />
}
isOnMenu={isOnMenu}
onElementChanged={setDraggableElement}
/>
)
}

View File

@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'
import { useSelectOrDelete } from '../../hooks'
import { DELETE_HITL_INPUT_BLOCK_COMMAND } from './'
import { UserEdit02 } from '@/app/components/base/icons/src/vender/solid/users'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
type QueryBlockComponentProps = {
nodeKey: string
@ -17,13 +18,49 @@ const HITLInputComponent: FC<QueryBlockComponentProps> = ({
}) => {
const { t } = useTranslation()
const [ref, isSelected] = useSelectOrDelete(nodeKey, DELETE_HITL_INPUT_BLOCK_COMMAND)
const [editor] = useLexicalComposerContext()
return (
<div
className={`
flex h-6 w-full items-center rounded-[5px] border-[1.5px] border-components-input-border-active bg-background-default-hover pl-1 pr-0.5 hover:bg-[#FFEAD5]
${isSelected && '!border-[#FD853A]'}
`}
// draggable
// onDragStart={(e) => {
// e.dataTransfer.setData('application/x-lexical-drag', nodeKey)
// e.dataTransfer.effectAllowed = 'move'
// console.log(`dragging node with key: ${nodeKey}`)
// }}
// onDragOver={(e) => {
// e.preventDefault()
// e.dataTransfer.dropEffect = 'move'
// }}
// onDragEnter={(e) => {
// e.preventDefault()
// e.currentTarget.classList.add('bg-[#FFEAD5]')
// }}
// onDragLeave={(e) => {
// e.currentTarget.classList.remove('bg-[#FFEAD5]')
// }}
// onDrop={(e) => {
// e.preventDefault()
// e.currentTarget.classList.remove('bg-[#FFEAD5]')
// const draggedNodeKey = e.dataTransfer.getData('application/x-lexical-drag')
// console.log('Drop event triggered with key:', draggedNodeKey)
// if (draggedNodeKey) {
// editor.update(() => {
// const draggedNode = $getNodeByKey(draggedNodeKey)
// const dropTarget = $getNodeByKey(nodeKey)
// if (draggedNode && dropTarget && draggedNode !== dropTarget) {
// console.log('Moving node in editor')
// dropTarget.insertAfter(draggedNode)
// }
// })
// }
// }}
ref={ref}
>
<UserEdit02 className='mr-1 h-[14px] w-[14px] text-[#FD853A]' />

View File

@ -9,6 +9,14 @@ export type SerializedNode = SerializedLexicalNode & {
export class HITLInputNode extends DecoratorNode<React.JSX.Element> {
__variableName: string
isIsolated(): boolean {
return true // This is necessary for drag-and-drop to work correctly
}
isTopLevel(): boolean {
return true // This is necessary for drag-and-drop to work correctly
}
static getType(): string {
return 'hitl-input-block'
}
@ -34,7 +42,7 @@ export class HITLInputNode extends DecoratorNode<React.JSX.Element> {
createDOM(): HTMLElement {
const div = document.createElement('div')
div.classList.add('flex', 'items-center', 'align-middle')
div.classList.add('flex', 'items-center', 'align-middle', 'support-drag')
return div
}