feat(web): enable block drag'n'drop in super editor (#2029)

This commit is contained in:
Aman Harwara
2022-11-18 21:15:18 +05:30
committed by GitHub
parent c847cc1d16
commit dab4f678f0
19 changed files with 119 additions and 201 deletions

View File

@@ -31,8 +31,6 @@ import FloatingLinkEditorPlugin from '../Lexical/Plugins/FloatingLinkEditorPlugi
import {truncateString} from './Utils';
import {SuperEditorContentId} from './Constants';
const BlockDragEnabled = false;
type BlocksEditorProps = {
onChange: (value: string, preview: string) => void;
className?: string;
@@ -130,11 +128,9 @@ export const BlocksEditor: FunctionComponent<BlocksEditorProps> = ({
<>
<FloatingTextFormatToolbarPlugin anchorElem={floatingAnchorElem} />
<FloatingLinkEditorPlugin />
<DraggableBlockPlugin anchorElem={floatingAnchorElem} />
</>
)}
{floatingAnchorElem && BlockDragEnabled && (
<>{<DraggableBlockPlugin anchorElem={floatingAnchorElem} />}</>
)}
</>
);
};

View File

@@ -10,10 +10,9 @@
}
.draggable-block-menu .icon {
width: 16px;
height: 16px;
opacity: 0.3;
background-image: url(#{$blocks-editor-icons-path}/draggable-block-menu.svg);
width: 1rem;
height: 1rem;
opacity: 0.4;
}
.draggable-block-menu:active {
@@ -21,13 +20,14 @@
}
.draggable-block-menu:hover {
background-color: #efefef;
background-color: var(--sn-stylekit-contrast-background-color);
padding: 3px;
}
.draggable-block-target-line {
pointer-events: none;
background: deepskyblue;
height: 4px;
background: var(--sn-stylekit-info-color);
height: 0.25rem;
position: absolute;
left: 0;
top: 0;

View File

@@ -19,16 +19,18 @@ import {
} from 'lexical';
import {DragEvent as ReactDragEvent, useEffect, useRef, useState} from 'react';
import {createPortal} from 'react-dom';
import {LexicalDraggableBlockMenu} from '@standardnotes/icons';
import {isHTMLElement} from '../../Utils/guard';
import {Point} from '../../Utils/point';
import {Rect} from '../../Utils/rect';
const SPACE = 4;
const SPACE = -16;
const TARGET_LINE_HALF_HEIGHT = 2;
const DRAGGABLE_BLOCK_MENU_CLASSNAME = 'draggable-block-menu';
const DRAG_DATA_FORMAT = 'application/x-lexical-drag-block';
const TEXT_BOX_HORIZONTAL_PADDING = 28;
const TARGET_LINE_SPACE_FROM_LEFT = 0;
const Downward = 1;
const Upward = -1;
@@ -179,7 +181,7 @@ function setTargetLine(
}
const top = lineTop - anchorTop - TARGET_LINE_HALF_HEIGHT;
const left = TEXT_BOX_HORIZONTAL_PADDING - SPACE;
const left = TARGET_LINE_SPACE_FROM_LEFT;
targetLineElem.style.transform = `translate(${left}px, ${top}px)`;
targetLineElem.style.width = `${
@@ -347,7 +349,9 @@ function useDraggableBlockMenu(
draggable={true}
onDragStart={onDragStart}
onDragEnd={onDragEnd}>
<div className={isEditable ? 'icon' : ''} />
<div className={isEditable ? 'icon' : ''}>
<LexicalDraggableBlockMenu className="text-text pointer-events-none" />
</div>
</div>
<div className="draggable-block-target-line" ref={targetLineRef} />
</>,

View File

@@ -27,6 +27,7 @@ import TypeSubscript from './type-subscript.svg'
import TypeSuperscript from './type-superscript.svg'
import TypeUnderline from './type-underline.svg'
import LexicalPencilFill from './pencil-fill.svg'
import LexicalDraggableBlockMenu from './draggable-block-menu.svg'
export {
LexicalCaretRightFill,
@@ -58,4 +59,5 @@ export {
TypeSuperscript,
TypeUnderline,
LexicalPencilFill,
LexicalDraggableBlockMenu,
}

View File

@@ -99,9 +99,6 @@
"prettier-plugin-tailwindcss": "^0.1.13",
"qrcode.react": "^3.1.0",
"react": "^18.2.0",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
"react-dnd-touch-backend": "^16.0.1",
"react-dom": "^18.2.0",
"react-refresh": "^0.14.0",
"sass-loader": "*",

View File

@@ -1,9 +1 @@
export enum ItemTypes {
TAG = 'TAG',
}
export type DropItemTag = { uuid: string }
export type DropItem = DropItemTag
export type DropProps = { isOver: boolean; canDrop: boolean }
export const TagDragDataFormat = 'application/x-sn-drag-tag'

View File

@@ -1,39 +1,50 @@
import Icon from '@/Components/Icon/Icon'
import { usePremiumModal } from '@/Hooks/usePremiumModal'
import { FeaturesController } from '@/Controllers/FeaturesController'
import { NavigationController } from '@/Controllers/Navigation/NavigationController'
import { classNames } from '@/Utils/ConcatenateClassNames'
import { observer } from 'mobx-react-lite'
import { FunctionComponent } from 'react'
import { useDrop } from 'react-dnd'
import { DropItem, DropProps, ItemTypes } from './DragNDrop'
import { DragEventHandler, FunctionComponent, useCallback, useState } from 'react'
import { TagDragDataFormat } from './DragNDrop'
type Props = {
tagsState: NavigationController
featuresState: FeaturesController
}
const RootTagDropZone: FunctionComponent<Props> = ({ tagsState }) => {
const premiumModal = usePremiumModal()
const [isOver, setIsOver] = useState(false)
const [{ isOver, canDrop }, dropRef] = useDrop<DropItem, void, DropProps>(
() => ({
accept: ItemTypes.TAG,
canDrop: (item) => {
return tagsState.hasParent(item.uuid)
},
drop: (item) => {
tagsState.assignParent(item.uuid, undefined).catch(console.error)
},
collect: (monitor) => ({
isOver: !!monitor.isOver(),
canDrop: !!monitor.canDrop(),
}),
}),
[tagsState, premiumModal],
const removeDragIndicator = useCallback(() => {
setIsOver(false)
}, [])
const onDragOver: DragEventHandler<HTMLDivElement> = useCallback((event): void => {
if (event.dataTransfer.types.includes(TagDragDataFormat)) {
event.preventDefault()
setIsOver(true)
}
}, [])
const onDrop: DragEventHandler<HTMLDivElement> = useCallback(
(event): void => {
setIsOver(false)
const draggedTagUuid = event.dataTransfer.getData(TagDragDataFormat)
if (!draggedTagUuid) {
return
}
if (draggedTagUuid) {
void tagsState.assignParent(draggedTagUuid, undefined)
}
},
[tagsState],
)
return (
<div ref={dropRef} className={`root-drop ${canDrop ? 'active' : ''} ${isOver ? 'is-drag-over' : ''}`}>
<div
className={classNames('root-drop', isOver && 'active is-drag-over')}
onDragExit={removeDragIndicator}
onDragOver={onDragOver}
onDragLeave={removeDragIndicator}
onDrop={onDrop}
>
<Icon className="text-neutral" type="link-off" />
<p className="content">
Move the tag here to <br />

View File

@@ -2,8 +2,6 @@ import { ViewControllerManager } from '@/Controllers/ViewControllerManager'
import { SNTag } from '@standardnotes/snjs'
import { observer } from 'mobx-react-lite'
import { FunctionComponent, useCallback } from 'react'
import { DndProvider } from 'react-dnd'
import { HTML5Backend } from 'react-dnd-html5-backend'
import RootTagDropZone from './RootTagDropZone'
import { TagListSectionType } from './TagListSection'
import { TagsListItem } from './TagsListItem'
@@ -17,8 +15,6 @@ const TagsList: FunctionComponent<Props> = ({ viewControllerManager, type }: Pro
const navigationController = viewControllerManager.navigationController
const allTags = type === 'all' ? navigationController.allLocalRootTags : navigationController.starredTags
const backend = HTML5Backend
const openTagContextMenu = useCallback(
(posX: number, posY: number) => {
viewControllerManager.navigationController.setContextMenuClickLocation({
@@ -40,7 +36,7 @@ const TagsList: FunctionComponent<Props> = ({ viewControllerManager, type }: Pro
)
return (
<DndProvider backend={backend}>
<>
{allTags.length === 0 ? (
<div className="no-tags-placeholder text-base opacity-[0.4] lg:text-sm">
No tags or folders. Create one using the add button above.
@@ -61,15 +57,10 @@ const TagsList: FunctionComponent<Props> = ({ viewControllerManager, type }: Pro
/>
)
})}
{type === 'all' && (
<RootTagDropZone
tagsState={viewControllerManager.navigationController}
featuresState={viewControllerManager.featuresController}
/>
)}
{type === 'all' && <RootTagDropZone tagsState={viewControllerManager.navigationController} />}
</>
)}
</DndProvider>
</>
)
}

View File

@@ -1,6 +1,5 @@
import Icon from '@/Components/Icon/Icon'
import { FOCUSABLE_BUT_NOT_TABBABLE, TAG_FOLDERS_FEATURE_NAME } from '@/Constants/Constants'
import { usePremiumModal } from '@/Hooks/usePremiumModal'
import { KeyboardKey } from '@standardnotes/ui-services'
import { FeaturesController } from '@/Controllers/FeaturesController'
import { NavigationController } from '@/Controllers/Navigation/NavigationController'
@@ -9,6 +8,7 @@ import { IconType, SNTag } from '@standardnotes/snjs'
import { computed } from 'mobx'
import { observer } from 'mobx-react-lite'
import {
DragEventHandler,
FormEventHandler,
FunctionComponent,
KeyboardEventHandler,
@@ -19,16 +19,15 @@ import {
useRef,
useState,
} from 'react'
import { useDrag, useDrop } from 'react-dnd'
import { DropItem, DropProps, ItemTypes } from './DragNDrop'
import { useResponsiveAppPane } from '../ResponsivePane/ResponsivePaneProvider'
import { AppPaneId } from '../ResponsivePane/AppPaneMetadata'
import { classNames } from '@/Utils/ConcatenateClassNames'
import { mergeRefs } from '@/Hooks/mergeRefs'
import { useFileDragNDrop } from '../FileDragNDropProvider/FileDragNDropProvider'
import { LinkingController } from '@/Controllers/LinkingController'
import { TagListSectionType } from './TagListSection'
import { log, LoggingDomain } from '@/Logging'
import { TagDragDataFormat } from './DragNDrop'
import { usePremiumModal } from '@/Hooks/usePremiumModal'
type Props = {
tag: SNTag
@@ -68,6 +67,8 @@ export const TagsListItem: FunctionComponent<Props> = observer(
const [showChildren, setShowChildren] = useState(tag.expanded)
const [hadChildren, setHadChildren] = useState(hasChildren)
const [isBeingDraggedOver, setIsBeingDraggedOver] = useState(false)
useEffect(() => {
if (!hadChildren && hasChildren) {
setShowChildren(true)
@@ -151,43 +152,6 @@ export const TagsListItem: FunctionComponent<Props> = observer(
}
}, [subtagInputRef, isAddingSubtag])
const [, dragRef] = useDrag(
() => ({
type: ItemTypes.TAG,
item: { uuid: tag.uuid },
canDrag: () => {
return true
},
collect: (monitor) => ({
isDragging: !!monitor.isDragging(),
}),
}),
[tag],
)
const [{ isOver, canDrop }, dropRef] = useDrop<DropItem, void, DropProps>(
() => ({
accept: ItemTypes.TAG,
canDrop: (item) => {
return navigationController.isValidTagParent(tag, item as SNTag)
},
drop: (item) => {
if (!hasFolders) {
premiumModal.activate(TAG_FOLDERS_FEATURE_NAME)
return
}
navigationController.assignParent(item.uuid, tag.uuid).catch(console.error)
},
collect: (monitor) => ({
isOver: !!monitor.isOver(),
canDrop: !!monitor.canDrop(),
}),
}),
[tag, navigationController, hasFolders, premiumModal],
)
const readyToDrop = isOver && canDrop
const toggleContextMenu: MouseEventHandler<HTMLAnchorElement> = useCallback(
(event) => {
event.preventDefault()
@@ -236,14 +200,59 @@ export const TagsListItem: FunctionComponent<Props> = observer(
log(LoggingDomain.NavigationList, 'Rendering TagsListItem')
const onDragStart: DragEventHandler<HTMLDivElement> = useCallback(
(event) => {
event.dataTransfer.setData(TagDragDataFormat, tag.uuid)
},
[tag.uuid],
)
const onDragEnter: DragEventHandler<HTMLDivElement> = useCallback((event): void => {
if (event.dataTransfer.types.includes(TagDragDataFormat)) {
event.preventDefault()
setIsBeingDraggedOver(true)
}
}, [])
const removeDragIndicator = useCallback(() => {
setIsBeingDraggedOver(false)
}, [])
const onDragOver: DragEventHandler<HTMLDivElement> = useCallback((event): void => {
if (event.dataTransfer.types.includes(TagDragDataFormat)) {
event.preventDefault()
}
}, [])
const onDrop: DragEventHandler<HTMLDivElement> = useCallback(
(event): void => {
setIsBeingDraggedOver(false)
const draggedTagUuid = event.dataTransfer.getData(TagDragDataFormat)
if (!draggedTagUuid) {
return
}
if (!navigationController.isValidTagParent(tag, { uuid: draggedTagUuid } as SNTag)) {
return
}
if (!hasFolders) {
premiumModal.activate(TAG_FOLDERS_FEATURE_NAME)
return
}
if (draggedTagUuid) {
void navigationController.assignParent(draggedTagUuid, tag.uuid)
}
},
[hasFolders, navigationController, premiumModal, tag],
)
return (
<>
<div
role="button"
tabIndex={FOCUSABLE_BUT_NOT_TABBABLE}
className={classNames('tag px-3.5', isSelected && 'selected', readyToDrop && 'is-drag-over')}
className={classNames('tag px-3.5', isSelected && 'selected', isBeingDraggedOver && 'is-drag-over')}
onClick={selectCurrentTag}
ref={mergeRefs([dragRef, tagRef])}
ref={tagRef}
style={{
paddingLeft: `${level * PADDING_PER_LEVEL_PX + PADDING_BASE_PX}px`,
}}
@@ -251,9 +260,16 @@ export const TagsListItem: FunctionComponent<Props> = observer(
e.preventDefault()
onContextMenu(tag, e.clientX, e.clientY)
}}
draggable={true}
onDragStart={onDragStart}
onDragEnter={onDragEnter}
onDragExit={removeDragIndicator}
onDragOver={onDragOver}
onDragLeave={removeDragIndicator}
onDrop={onDrop}
>
<div className="tag-info" title={title} ref={dropRef}>
<div onClick={selectCurrentTag} className={'tag-icon draggable mr-2'} ref={dragRef}>
<div className="tag-info" title={title}>
<div onClick={selectCurrentTag} className={'tag-icon draggable mr-2'}>
<Icon
type={tag.iconString as IconType}
className={`cursor-pointer ${isSelected ? 'text-info' : 'text-neutral'}`}

View File

@@ -36,10 +36,7 @@ $content-horizontal-padding: 16px;
flex-direction: row;
align-items: center;
justify-content: center;
.sn-icon {
margin-right: 0.5rem;
}
gap: 0.5rem;
&.active {
opacity: 1;