feat(richtext-lexical): improve block dragging UX
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user