feat(richtext-lexical): improve block dragging UX

This commit is contained in:
Alessio Gravili
2024-05-22 13:55:44 -04:00
parent c93752bdbb
commit 6b45cf3197
3 changed files with 168 additions and 33 deletions

View File

@@ -45,7 +45,7 @@
.draggable-block-target-line {
pointer-events: none;
background: var(--theme-elevation-200);
border: 1px solid var(--theme-elevation-650);
//border: 1px solid var(--theme-elevation-650);
border-radius: 4px;
height: 50px;
position: absolute;

View File

@@ -57,11 +57,10 @@ function hideTargetLine(
}
if (lastTargetBlockElem) {
lastTargetBlockElem.style.opacity = ''
lastTargetBlockElem.style.transform = ''
// Delete marginBottom and marginTop values we set
lastTargetBlockElem.style.marginBottom = ''
lastTargetBlockElem.style.marginTop = ''
//lastTargetBlockElem.style.border = 'none'
//lastTargetBlock.style.border = 'none'
}
}
@@ -77,7 +76,12 @@ function useDraggableBlockMenu(
const debugHighlightRef = useRef<HTMLDivElement>(null)
const isDraggingBlockRef = useRef<boolean>(false)
const [draggableBlockElem, setDraggableBlockElem] = useState<HTMLElement | null>(null)
const [lastTargetBlockElem, setLastTargetBlockElem] = useState<HTMLElement | null>(null)
const [lastTargetBlock, setLastTargetBlock] = useState<{
boundingBox?: DOMRect
elem: HTMLElement | null
isBelow: boolean
}>(null)
const { editorConfig } = useEditorConfigContext()
const blockHandleHorizontalOffset = editorConfig?.admin?.hideGutter ? -44 : -8
@@ -211,7 +215,7 @@ function useDraggableBlockMenu(
}
if (draggableBlockElem !== targetBlockElem) {
setTargetLine(
const { isBelow, willStayInSamePosition } = setTargetLine(
editorConfig?.admin?.hideGutter ? '0px' : '3rem',
blockHandleHorizontalOffset +
(editorConfig?.admin?.hideGutter
@@ -219,7 +223,7 @@ function useDraggableBlockMenu(
: -menuRef?.current?.getBoundingClientRect()?.width ?? 0),
targetLineElem,
targetBlockElem,
lastTargetBlockElem,
lastTargetBlock,
pageY,
anchorElem,
event,
@@ -231,11 +235,22 @@ function useDraggableBlockMenu(
// Calling preventDefault() adds the green plus icon to the cursor,
// indicating that the drop is allowed.
event.preventDefault()
} else {
hideTargetLine(targetLineElem, lastTargetBlockElem)
}
setLastTargetBlockElem(targetBlockElem)
if (!willStayInSamePosition) {
setLastTargetBlock({
boundingBox: targetBlockElem.getBoundingClientRect(),
elem: targetBlockElem,
isBelow,
})
}
} else {
hideTargetLine(targetLineElem, lastTargetBlock?.elem)
setLastTargetBlock({
boundingBox: targetBlockElem.getBoundingClientRect(),
elem: targetBlockElem,
isBelow: false,
})
}
return true
}
@@ -317,6 +332,51 @@ function useDraggableBlockMenu(
if (draggableBlockElem !== null) {
setDraggableBlockElem(null)
}
// find all previous elements with lexical-block-highlighter class and remove them
const allPrevHighlighters = document.querySelectorAll('.lexical-block-highlighter')
allPrevHighlighters.forEach((highlighter) => {
highlighter.remove()
})
const newInsertedElem = editor.getElementByKey(draggedNode.getKey())
setTimeout(() => {
// add new temp html element to newInsertedElem with the same height and width and the class block-selected
// to highlight the new inserted element
const newInsertedElemRect = newInsertedElem.getBoundingClientRect()
const highlightElem = document.createElement('div')
highlightElem.className = 'lexical-block-highlighter'
// if html data-theme is dark, set the highlighter color to white
if (document.documentElement.getAttribute('data-theme') === 'dark') {
highlightElem.style.backgroundColor = 'white'
} else {
highlightElem.style.backgroundColor = 'black'
}
highlightElem.style.transition = 'opacity 0.1s ease-in-out'
highlightElem.style.zIndex = '1'
highlightElem.style.pointerEvents = 'none'
highlightElem.style.boxSizing = 'border-box'
highlightElem.style.borderRadius = '4px'
highlightElem.style.position = 'absolute'
document.body.appendChild(highlightElem)
highlightElem.style.opacity = '0.1'
highlightElem.style.height = `${newInsertedElemRect.height + 8}px`
highlightElem.style.width = `${newInsertedElemRect.width + 8}px`
highlightElem.style.top = `${newInsertedElemRect.top + window.scrollY - 4}px`
highlightElem.style.left = `${newInsertedElemRect.left - 4}px`
setTimeout(() => {
highlightElem.style.opacity = '0'
setTimeout(() => {
highlightElem.remove()
}, 1000)
}, 3000)
}, 120)
})
return true
@@ -336,8 +396,9 @@ function useDraggableBlockMenu(
blockHandleHorizontalOffset,
anchorElem,
editor,
lastTargetBlockElem,
lastTargetBlock,
draggableBlockElem,
editorConfig?.admin?.hideGutter,
])
function onDragStart(event: ReactDragEvent<HTMLDivElement>): void {
@@ -359,7 +420,7 @@ function useDraggableBlockMenu(
function onDragEnd(): void {
isDraggingBlockRef.current = false
hideTargetLine(targetLineRef.current, lastTargetBlockElem)
hideTargetLine(targetLineRef.current, lastTargetBlock?.elem)
}
return createPortal(

View File

@@ -5,12 +5,18 @@ const TARGET_LINE_HALF_HEIGHT = 25
const TEXT_BOX_HORIZONTAL_PADDING = -24
const DEBUG = false
let animationTimer = 0
export function setTargetLine(
offsetWidth: string,
offsetLeft: number,
targetLineElem: HTMLElement,
targetBlockElem: HTMLElement,
lastTargetBlockElem: HTMLElement | null,
lastTargetBlock: {
boundingBox?: DOMRect
elem: HTMLElement | null
isBelow: boolean
},
mouseY: number,
anchorElem: HTMLElement,
event: DragEvent,
@@ -18,14 +24,71 @@ export function setTargetLine(
isFoundNodeEmptyParagraph: boolean = false,
) {
const { height: targetBlockElemHeight, top: targetBlockElemTop } =
getBoundingClientRectWithoutTransform(targetBlockElem)
targetBlockElem.getBoundingClientRect() // used to be getBoundingClientRectWithoutTransform. Not sure what's better, but the normal getBoundingClientRect seems to work fine
const { top: anchorTop, width: anchorWidth } = anchorElem.getBoundingClientRect()
const { marginBottom, marginTop } = getCollapsedMargins(targetBlockElem)
let lineTop = targetBlockElemTop
const isBelow = mouseY >= targetBlockElemTop + targetBlockElemHeight / 2 + window.scrollY
let willStayInSamePosition = false
/**
* 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)
* This prevents unnecessary flickering.
*
* 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
*/
if (lastTargetBlock?.elem) {
if (targetBlockElem !== lastTargetBlock?.elem) {
if (
isBelow &&
lastTargetBlock?.elem &&
lastTargetBlock?.elem === targetBlockElem.nextElementSibling
) {
animationTimer++
if (animationTimer < 200) {
willStayInSamePosition = true
}
} else if (
!isBelow &&
lastTargetBlock?.elem &&
lastTargetBlock?.elem === targetBlockElem.previousElementSibling
) {
animationTimer++
if (animationTimer < 200) {
willStayInSamePosition = true
}
}
} else {
animationTimer++
const lastBoundingBoxPosition = lastTargetBlock?.boundingBox?.y
const currentBoundingBoxPosition = targetBlockElem.getBoundingClientRect().y
if (
(isBelow === lastTargetBlock?.isBelow &&
lastBoundingBoxPosition === currentBoundingBoxPosition) ||
animationTimer < 200
) {
willStayInSamePosition = false
}
}
}
if (willStayInSamePosition) {
return {
isBelow,
willStayInSamePosition,
}
}
/**
* Paragraphs need no isBelow/above handling,
*/
if (!isFoundNodeEmptyParagraph) {
//if (!isFoundNodeEmptyParagraph) {
if (isBelow) {
// below targetBlockElem
lineTop += targetBlockElemHeight + marginBottom / 2
@@ -37,7 +100,6 @@ export function setTargetLine(
lineTop += targetBlockElemHeight / 2
}
const targetElemTranslate = 0
let targetElemTranslate2 = 0
if (!isFoundNodeEmptyParagraph) {
@@ -48,29 +110,30 @@ export function setTargetLine(
}
}
let top = lineTop - anchorTop + targetElemTranslate2
if (!isBelow) {
top -= TARGET_LINE_HALF_HEIGHT * 2
}
const top = lineTop - anchorTop + targetElemTranslate2
const left = TEXT_BOX_HORIZONTAL_PADDING - offsetLeft
targetLineElem.style.transform = `translate(${left}px, ${top}px)`
targetLineElem.style.width = `calc(${anchorWidth}px - ${offsetWidth})`
targetLineElem.style.opacity = '.4'
/**
* Move around element below or above the line (= the target / targetBlockElem)
* Move around element below or above the line (= the target / targetBlockElem). Creates "space" for the targetLineElem
*
* Not needed for empty paragraphs, as an empty paragraph is enough space for the targetLineElem anyways.
*/
//targetBlockElem.style.opacity = '0.4'
const buffer = 12 // creates more spacing/padding so target line is not directly next to the targetBlockElem
if (!isFoundNodeEmptyParagraph) {
// move lastTargetBlockElem down 50px to make space for targetLineElem (which is 50px height)
targetBlockElem.style.transform = `translate(0, ${targetElemTranslate}px)`
if (isBelow) {
// add to existing marginBottom plus the height of targetLineElem
targetBlockElem.style.marginBottom = TARGET_LINE_HALF_HEIGHT * 2 + 'px'
targetBlockElem.style.marginBottom = TARGET_LINE_HALF_HEIGHT * 2 + buffer + 'px'
targetLineElem.style.transform = `translate(${left}px, calc(${top}px - ${'0px'}))`
} else {
targetBlockElem.style.marginTop = TARGET_LINE_HALF_HEIGHT * 2 + 'px'
targetBlockElem.style.marginTop = TARGET_LINE_HALF_HEIGHT * 2 + buffer + 'px'
targetLineElem.style.transform = `translate(${left}px, calc(${top - TARGET_LINE_HALF_HEIGHT * 2}px - ${'0px'}))`
}
} else {
targetLineElem.style.transform = `translate(${left}px, ${top - TARGET_LINE_HALF_HEIGHT}px)`
}
if (DEBUG) {
@@ -78,13 +141,24 @@ export function setTargetLine(
highlightElemOriginalPosition(debugHighlightRef, targetBlockElem, anchorElem)
}
if (lastTargetBlockElem && lastTargetBlockElem !== targetBlockElem) {
lastTargetBlockElem.style.opacity = ''
lastTargetBlockElem.style.transform = ''
/**
* Properly reset previous targetBlockElem styles
*/
lastTargetBlock.elem.style.opacity = ''
// Delete marginBottom and marginTop values we set
lastTargetBlockElem.style.marginBottom = ''
lastTargetBlockElem.style.marginTop = ''
//lastTargetBlockElem.style.border = 'none'
if (lastTargetBlock?.elem === targetBlockElem) {
if (isBelow) {
lastTargetBlock.elem.style.marginTop = ''
} else {
lastTargetBlock.elem.style.marginBottom = ''
}
} else {
lastTargetBlock.elem.style.marginBottom = ''
lastTargetBlock.elem.style.marginTop = ''
}
animationTimer = 0
return {
isBelow,
willStayInSamePosition,
}
}