Skip to content

Commit 6b45cf3

Browse files
committed
feat(richtext-lexical): improve block dragging UX
1 parent c93752b commit 6b45cf3

File tree

3 files changed

+167
-32
lines changed

3 files changed

+167
-32
lines changed

packages/richtext-lexical/src/field/lexical/plugins/handles/DraggableBlockPlugin/index.scss

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@
4545
.draggable-block-target-line {
4646
pointer-events: none;
4747
background: var(--theme-elevation-200);
48-
border: 1px solid var(--theme-elevation-650);
48+
//border: 1px solid var(--theme-elevation-650);
4949
border-radius: 4px;
5050
height: 50px;
5151
position: absolute;

packages/richtext-lexical/src/field/lexical/plugins/handles/DraggableBlockPlugin/index.tsx

Lines changed: 71 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -57,11 +57,10 @@ function hideTargetLine(
5757
}
5858
if (lastTargetBlockElem) {
5959
lastTargetBlockElem.style.opacity = ''
60-
lastTargetBlockElem.style.transform = ''
6160
// Delete marginBottom and marginTop values we set
6261
lastTargetBlockElem.style.marginBottom = ''
6362
lastTargetBlockElem.style.marginTop = ''
64-
//lastTargetBlockElem.style.border = 'none'
63+
//lastTargetBlock.style.border = 'none'
6564
}
6665
}
6766

@@ -77,7 +76,12 @@ function useDraggableBlockMenu(
7776
const debugHighlightRef = useRef<HTMLDivElement>(null)
7877
const isDraggingBlockRef = useRef<boolean>(false)
7978
const [draggableBlockElem, setDraggableBlockElem] = useState<HTMLElement | null>(null)
80-
const [lastTargetBlockElem, setLastTargetBlockElem] = useState<HTMLElement | null>(null)
79+
const [lastTargetBlock, setLastTargetBlock] = useState<{
80+
boundingBox?: DOMRect
81+
elem: HTMLElement | null
82+
isBelow: boolean
83+
}>(null)
84+
8185
const { editorConfig } = useEditorConfigContext()
8286

8387
const blockHandleHorizontalOffset = editorConfig?.admin?.hideGutter ? -44 : -8
@@ -211,15 +215,15 @@ function useDraggableBlockMenu(
211215
}
212216

213217
if (draggableBlockElem !== targetBlockElem) {
214-
setTargetLine(
218+
const { isBelow, willStayInSamePosition } = setTargetLine(
215219
editorConfig?.admin?.hideGutter ? '0px' : '3rem',
216220
blockHandleHorizontalOffset +
217221
(editorConfig?.admin?.hideGutter
218222
? menuRef?.current?.getBoundingClientRect()?.width ?? 0
219223
: -menuRef?.current?.getBoundingClientRect()?.width ?? 0),
220224
targetLineElem,
221225
targetBlockElem,
222-
lastTargetBlockElem,
226+
lastTargetBlock,
223227
pageY,
224228
anchorElem,
225229
event,
@@ -231,12 +235,23 @@ function useDraggableBlockMenu(
231235
// Calling preventDefault() adds the green plus icon to the cursor,
232236
// indicating that the drop is allowed.
233237
event.preventDefault()
238+
239+
if (!willStayInSamePosition) {
240+
setLastTargetBlock({
241+
boundingBox: targetBlockElem.getBoundingClientRect(),
242+
elem: targetBlockElem,
243+
isBelow,
244+
})
245+
}
234246
} else {
235-
hideTargetLine(targetLineElem, lastTargetBlockElem)
247+
hideTargetLine(targetLineElem, lastTargetBlock?.elem)
248+
setLastTargetBlock({
249+
boundingBox: targetBlockElem.getBoundingClientRect(),
250+
elem: targetBlockElem,
251+
isBelow: false,
252+
})
236253
}
237254

238-
setLastTargetBlockElem(targetBlockElem)
239-
240255
return true
241256
}
242257

@@ -317,6 +332,51 @@ function useDraggableBlockMenu(
317332
if (draggableBlockElem !== null) {
318333
setDraggableBlockElem(null)
319334
}
335+
336+
// find all previous elements with lexical-block-highlighter class and remove them
337+
const allPrevHighlighters = document.querySelectorAll('.lexical-block-highlighter')
338+
allPrevHighlighters.forEach((highlighter) => {
339+
highlighter.remove()
340+
})
341+
342+
const newInsertedElem = editor.getElementByKey(draggedNode.getKey())
343+
setTimeout(() => {
344+
// add new temp html element to newInsertedElem with the same height and width and the class block-selected
345+
// to highlight the new inserted element
346+
const newInsertedElemRect = newInsertedElem.getBoundingClientRect()
347+
348+
const highlightElem = document.createElement('div')
349+
highlightElem.className = 'lexical-block-highlighter'
350+
351+
// if html data-theme is dark, set the highlighter color to white
352+
if (document.documentElement.getAttribute('data-theme') === 'dark') {
353+
highlightElem.style.backgroundColor = 'white'
354+
} else {
355+
highlightElem.style.backgroundColor = 'black'
356+
}
357+
358+
highlightElem.style.transition = 'opacity 0.1s ease-in-out'
359+
highlightElem.style.zIndex = '1'
360+
highlightElem.style.pointerEvents = 'none'
361+
highlightElem.style.boxSizing = 'border-box'
362+
highlightElem.style.borderRadius = '4px'
363+
highlightElem.style.position = 'absolute'
364+
document.body.appendChild(highlightElem)
365+
366+
highlightElem.style.opacity = '0.1'
367+
368+
highlightElem.style.height = `${newInsertedElemRect.height + 8}px`
369+
highlightElem.style.width = `${newInsertedElemRect.width + 8}px`
370+
highlightElem.style.top = `${newInsertedElemRect.top + window.scrollY - 4}px`
371+
highlightElem.style.left = `${newInsertedElemRect.left - 4}px`
372+
373+
setTimeout(() => {
374+
highlightElem.style.opacity = '0'
375+
setTimeout(() => {
376+
highlightElem.remove()
377+
}, 1000)
378+
}, 3000)
379+
}, 120)
320380
})
321381

322382
return true
@@ -336,8 +396,9 @@ function useDraggableBlockMenu(
336396
blockHandleHorizontalOffset,
337397
anchorElem,
338398
editor,
339-
lastTargetBlockElem,
399+
lastTargetBlock,
340400
draggableBlockElem,
401+
editorConfig?.admin?.hideGutter,
341402
])
342403

343404
function onDragStart(event: ReactDragEvent<HTMLDivElement>): void {
@@ -359,7 +420,7 @@ function useDraggableBlockMenu(
359420

360421
function onDragEnd(): void {
361422
isDraggingBlockRef.current = false
362-
hideTargetLine(targetLineRef.current, lastTargetBlockElem)
423+
hideTargetLine(targetLineRef.current, lastTargetBlock?.elem)
363424
}
364425

365426
return createPortal(

packages/richtext-lexical/src/field/lexical/plugins/handles/DraggableBlockPlugin/setTargetLine.ts

Lines changed: 95 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -5,27 +5,90 @@ const TARGET_LINE_HALF_HEIGHT = 25
55
const TEXT_BOX_HORIZONTAL_PADDING = -24
66
const DEBUG = false
77

8+
let animationTimer = 0
9+
810
export function setTargetLine(
911
offsetWidth: string,
1012
offsetLeft: number,
1113
targetLineElem: HTMLElement,
1214
targetBlockElem: HTMLElement,
13-
lastTargetBlockElem: HTMLElement | null,
15+
lastTargetBlock: {
16+
boundingBox?: DOMRect
17+
elem: HTMLElement | null
18+
isBelow: boolean
19+
},
1420
mouseY: number,
1521
anchorElem: HTMLElement,
1622
event: DragEvent,
1723
debugHighlightRef: React.RefObject<HTMLDivElement>,
1824
isFoundNodeEmptyParagraph: boolean = false,
1925
) {
2026
const { height: targetBlockElemHeight, top: targetBlockElemTop } =
21-
getBoundingClientRectWithoutTransform(targetBlockElem)
27+
targetBlockElem.getBoundingClientRect() // used to be getBoundingClientRectWithoutTransform. Not sure what's better, but the normal getBoundingClientRect seems to work fine
2228
const { top: anchorTop, width: anchorWidth } = anchorElem.getBoundingClientRect()
2329

2430
const { marginBottom, marginTop } = getCollapsedMargins(targetBlockElem)
2531
let lineTop = targetBlockElemTop
2632

2733
const isBelow = mouseY >= targetBlockElemTop + targetBlockElemHeight / 2 + window.scrollY
34+
35+
let willStayInSamePosition = false
36+
37+
/**
38+
* Do not run any transform or changes if the actual new line position would be the same (even if it's now inserted BEFORE rather than AFTER - position would still be the same)
39+
* This prevents unnecessary flickering.
40+
*
41+
* We still need to let it run even if the position (IGNORING the transform) would not change, as the transform animation is not finished yet. This is what animationTimer does. Otherwise, the positioning will be inaccurate
42+
*/
43+
if (lastTargetBlock?.elem) {
44+
if (targetBlockElem !== lastTargetBlock?.elem) {
45+
if (
46+
isBelow &&
47+
lastTargetBlock?.elem &&
48+
lastTargetBlock?.elem === targetBlockElem.nextElementSibling
49+
) {
50+
animationTimer++
51+
52+
if (animationTimer < 200) {
53+
willStayInSamePosition = true
54+
}
55+
} else if (
56+
!isBelow &&
57+
lastTargetBlock?.elem &&
58+
lastTargetBlock?.elem === targetBlockElem.previousElementSibling
59+
) {
60+
animationTimer++
61+
if (animationTimer < 200) {
62+
willStayInSamePosition = true
63+
}
64+
}
65+
} else {
66+
animationTimer++
67+
68+
const lastBoundingBoxPosition = lastTargetBlock?.boundingBox?.y
69+
const currentBoundingBoxPosition = targetBlockElem.getBoundingClientRect().y
70+
71+
if (
72+
(isBelow === lastTargetBlock?.isBelow &&
73+
lastBoundingBoxPosition === currentBoundingBoxPosition) ||
74+
animationTimer < 200
75+
) {
76+
willStayInSamePosition = false
77+
}
78+
}
79+
}
80+
if (willStayInSamePosition) {
81+
return {
82+
isBelow,
83+
willStayInSamePosition,
84+
}
85+
}
86+
87+
/**
88+
* Paragraphs need no isBelow/above handling,
89+
*/
2890
if (!isFoundNodeEmptyParagraph) {
91+
//if (!isFoundNodeEmptyParagraph) {
2992
if (isBelow) {
3093
// below targetBlockElem
3194
lineTop += targetBlockElemHeight + marginBottom / 2
@@ -37,7 +100,6 @@ export function setTargetLine(
37100
lineTop += targetBlockElemHeight / 2
38101
}
39102

40-
const targetElemTranslate = 0
41103
let targetElemTranslate2 = 0
42104

43105
if (!isFoundNodeEmptyParagraph) {
@@ -48,43 +110,55 @@ export function setTargetLine(
48110
}
49111
}
50112

51-
let top = lineTop - anchorTop + targetElemTranslate2
52-
if (!isBelow) {
53-
top -= TARGET_LINE_HALF_HEIGHT * 2
54-
}
113+
const top = lineTop - anchorTop + targetElemTranslate2
114+
55115
const left = TEXT_BOX_HORIZONTAL_PADDING - offsetLeft
56116

57-
targetLineElem.style.transform = `translate(${left}px, ${top}px)`
58117
targetLineElem.style.width = `calc(${anchorWidth}px - ${offsetWidth})`
59118
targetLineElem.style.opacity = '.4'
60119

61120
/**
62-
* Move around element below or above the line (= the target / targetBlockElem)
121+
* Move around element below or above the line (= the target / targetBlockElem). Creates "space" for the targetLineElem
122+
*
123+
* Not needed for empty paragraphs, as an empty paragraph is enough space for the targetLineElem anyways.
63124
*/
64125
//targetBlockElem.style.opacity = '0.4'
126+
const buffer = 12 // creates more spacing/padding so target line is not directly next to the targetBlockElem
65127
if (!isFoundNodeEmptyParagraph) {
66-
// move lastTargetBlockElem down 50px to make space for targetLineElem (which is 50px height)
67-
targetBlockElem.style.transform = `translate(0, ${targetElemTranslate}px)`
68128
if (isBelow) {
69-
// add to existing marginBottom plus the height of targetLineElem
70-
targetBlockElem.style.marginBottom = TARGET_LINE_HALF_HEIGHT * 2 + 'px'
129+
targetBlockElem.style.marginBottom = TARGET_LINE_HALF_HEIGHT * 2 + buffer + 'px'
130+
targetLineElem.style.transform = `translate(${left}px, calc(${top}px - ${'0px'}))`
71131
} else {
72-
targetBlockElem.style.marginTop = TARGET_LINE_HALF_HEIGHT * 2 + 'px'
132+
targetBlockElem.style.marginTop = TARGET_LINE_HALF_HEIGHT * 2 + buffer + 'px'
133+
targetLineElem.style.transform = `translate(${left}px, calc(${top - TARGET_LINE_HALF_HEIGHT * 2}px - ${'0px'}))`
73134
}
135+
} else {
136+
targetLineElem.style.transform = `translate(${left}px, ${top - TARGET_LINE_HALF_HEIGHT}px)`
74137
}
75138

76139
if (DEBUG) {
77140
//targetBlockElem.style.border = '3px solid red'
78141
highlightElemOriginalPosition(debugHighlightRef, targetBlockElem, anchorElem)
79142
}
80143

81-
if (lastTargetBlockElem && lastTargetBlockElem !== targetBlockElem) {
82-
lastTargetBlockElem.style.opacity = ''
83-
lastTargetBlockElem.style.transform = ''
144+
/**
145+
* Properly reset previous targetBlockElem styles
146+
*/
147+
lastTargetBlock.elem.style.opacity = ''
84148

85-
// Delete marginBottom and marginTop values we set
86-
lastTargetBlockElem.style.marginBottom = ''
87-
lastTargetBlockElem.style.marginTop = ''
88-
//lastTargetBlockElem.style.border = 'none'
149+
if (lastTargetBlock?.elem === targetBlockElem) {
150+
if (isBelow) {
151+
lastTargetBlock.elem.style.marginTop = ''
152+
} else {
153+
lastTargetBlock.elem.style.marginBottom = ''
154+
}
155+
} else {
156+
lastTargetBlock.elem.style.marginBottom = ''
157+
lastTargetBlock.elem.style.marginTop = ''
158+
}
159+
animationTimer = 0
160+
return {
161+
isBelow,
162+
willStayInSamePosition,
89163
}
90164
}

0 commit comments

Comments
 (0)