Skip to content

Commit fafbfc4

Browse files
authored
✨ feat(block): configurable anchor padding & base-ui dropdown (#169)
1 parent 0a1b14d commit fafbfc4

9 files changed

Lines changed: 223 additions & 172 deletions

File tree

src/plugins/block/plugin/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,15 @@ import { registerBlockMoveCommand } from '../command';
2323
import { BlockMenuService, IBlockMenuService } from '../service';
2424

2525
export interface BlockPluginOptions {
26+
/**
27+
* Inline padding reserved on the editor root so the floating block menu /
28+
* drag handle has somewhere to render without overlapping the block content.
29+
* Pass `0` (or `'0'`) when the surrounding layout already provides enough
30+
* left gutter. Accepts a number (treated as px) or any valid CSS
31+
* `padding-inline` value (e.g. `'40px 0'`). When omitted, defaults to 54px
32+
* on each side.
33+
*/
34+
anchorPadding?: number | string;
2635
attributeName?: string;
2736
className?: string;
2837
}

src/plugins/block/react/ReactBlockPlugin.tsx

Lines changed: 91 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
'use client';
22

33
import { $findTableNode, $isTableSelection } from '@lexical/table';
4-
import { Icon } from '@lobehub/ui';
5-
import { Button, Dropdown, theme } from 'antd';
4+
import { DropdownMenu, type DropdownMenuProps, Icon, useAppElement } from '@lobehub/ui';
5+
import { Button, theme } from 'antd';
66
import { cx } from 'antd-style';
77
import { $getNodeByKey, $getSelection, $isRangeSelection } from 'lexical';
88
import { GripVerticalIcon, PlusIcon } from 'lucide-react';
@@ -40,7 +40,7 @@ import {
4040
getTableBlockRect,
4141
isTableBlockElement,
4242
} from './drag/drag-utils';
43-
import { styles } from './style';
43+
import { ANCHOR_PADDING_CSS_VAR, styles } from './style';
4444

4545
export interface ReactBlockPluginProps extends Omit<BlockPluginOptions, 'className'> {
4646
className?: string;
@@ -72,16 +72,22 @@ const getTableMenuAnchorRect = (element: HTMLElement) => {
7272
const ReactBlockPlugin: FC<ReactBlockPluginProps> = (props) => {
7373
const { token } = theme.useToken();
7474
const [editor] = useLexicalComposerContext();
75+
const appElement = useAppElement();
7576
const {
7677
rootClassName,
7778
className,
7879
attributeName,
80+
anchorPadding,
7981
locale,
8082
onHoverBlockChange,
8183
onDragTargetChange,
8284
onDragTargetResolve,
8385
} = props;
8486
const mergedRootClassName = cx(styles.root, rootClassName?.trim() || className?.trim());
87+
const anchorPaddingValue = useMemo(() => {
88+
if (anchorPadding === undefined) return null;
89+
return typeof anchorPadding === 'number' ? `${anchorPadding}px` : anchorPadding;
90+
}, [anchorPadding]);
8591
const menuRef = useRef<HTMLDivElement>(null);
8692
const dragLayerRef = useRef<HTMLDivElement>(null);
8793
const contextRef = useRef<RuntimeContextRef>(createRuntimeContext());
@@ -110,10 +116,27 @@ const ReactBlockPlugin: FC<ReactBlockPluginProps> = (props) => {
110116
}
111117

112118
editor.registerPlugin(BlockPlugin, {
119+
anchorPadding,
113120
attributeName,
114121
className: mergedRootClassName,
115122
});
116-
}, [attributeName, editor, locale, mergedRootClassName]);
123+
}, [anchorPadding, attributeName, editor, locale, mergedRootClassName]);
124+
125+
useLexicalEditor(
126+
(lexicalEditor) =>
127+
lexicalEditor.registerRootListener((rootElement, prevRootElement) => {
128+
if (prevRootElement) {
129+
prevRootElement.style.removeProperty(ANCHOR_PADDING_CSS_VAR);
130+
}
131+
if (!rootElement) return;
132+
if (anchorPaddingValue === null) {
133+
rootElement.style.removeProperty(ANCHOR_PADDING_CSS_VAR);
134+
} else {
135+
rootElement.style.setProperty(ANCHOR_PADDING_CSS_VAR, anchorPaddingValue);
136+
}
137+
}),
138+
[anchorPaddingValue],
139+
);
117140

118141
useLexicalEditor(() => {
119142
const service = editor.requireService(IBlockMenuService) as BlockMenuService | null;
@@ -625,8 +648,44 @@ const ReactBlockPlugin: FC<ReactBlockPluginProps> = (props) => {
625648
};
626649
}, [blockMenuSuppressed, editor, isDragging]);
627650

651+
const menuContext = useMemo<IBlockMenuRenderContext | null>(() => {
652+
if (operationMenuOpen && operationMenuContext) {
653+
return operationMenuContext;
654+
}
655+
656+
const lockedContext = blockMenuService?.getMenuLockedContext();
657+
if (lockedContext) {
658+
const root = editor.getRootElement();
659+
const fresh = root?.querySelector<HTMLElement>(
660+
`[data-block-id="${CSS.escape(lockedContext.blockId)}"]`,
661+
);
662+
if (fresh && root?.contains(fresh)) {
663+
return {
664+
blockElement: fresh,
665+
blockId: lockedContext.blockId,
666+
editor,
667+
};
668+
}
669+
}
670+
671+
if (!hoveredBlock) return null;
672+
673+
return {
674+
blockElement: hoveredBlock.blockElement,
675+
blockId: hoveredBlock.blockId,
676+
editor,
677+
};
678+
}, [
679+
editor,
680+
hoveredBlock,
681+
operationMenuOpen,
682+
operationMenuContext,
683+
blockMenuService,
684+
menuVersion,
685+
]);
686+
628687
useLayoutEffect(() => {
629-
if (!hoveredBlock) {
688+
if (!menuContext) {
630689
if (operationMenuOpen) {
631690
return;
632691
}
@@ -636,20 +695,20 @@ const ReactBlockPlugin: FC<ReactBlockPluginProps> = (props) => {
636695
}
637696

638697
const updateMenuPosition = () => {
639-
const blockRect = getBlockMeasureRect(hoveredBlock.blockElement);
698+
const blockRect = getBlockMeasureRect(menuContext.blockElement);
640699
if (!blockRect) {
641700
setMenuPosition({});
642701
return;
643702
}
644703

645704
const menuWidth = menuRef.current?.offsetWidth || 32;
646705
const gap = 8;
647-
const listItemOffset = hoveredBlock.blockElement.tagName === 'LI' ? 16 : 0;
648-
const isTableBlock = isTableBlockElement(hoveredBlock.blockElement);
649-
const isFocusedTableBlock = focusedTableBlockId === hoveredBlock.blockId && isTableBlock;
706+
const listItemOffset = menuContext.blockElement.tagName === 'LI' ? 16 : 0;
707+
const isTableBlock = isTableBlockElement(menuContext.blockElement);
708+
const isFocusedTableBlock = focusedTableBlockId === menuContext.blockId && isTableBlock;
650709
const tableMenuOffset = isFocusedTableBlock ? TABLE_FOCUSED_MENU_OFFSET : 0;
651710
const tableAnchorRect = isTableBlock
652-
? getTableMenuAnchorRect(hoveredBlock.blockElement)
711+
? getTableMenuAnchorRect(menuContext.blockElement)
653712
: null;
654713
const root = editor.getRootElement();
655714
const rootRect = root?.getBoundingClientRect();
@@ -683,17 +742,7 @@ const ReactBlockPlugin: FC<ReactBlockPluginProps> = (props) => {
683742
window.removeEventListener('resize', updateMenuPosition);
684743
document.removeEventListener('scroll', updateMenuPosition, true);
685744
};
686-
}, [editor, focusedTableBlockId, hoveredBlock, layoutVersion, operationMenuOpen]);
687-
688-
const menuContext = useMemo<IBlockMenuRenderContext | null>(() => {
689-
if (!hoveredBlock) return null;
690-
691-
return {
692-
blockElement: hoveredBlock.blockElement,
693-
blockId: hoveredBlock.blockId,
694-
editor,
695-
};
696-
}, [editor, hoveredBlock]);
745+
}, [editor, focusedTableBlockId, menuContext, layoutVersion, operationMenuOpen]);
697746

698747
const operationMenus = useMemo(() => {
699748
if (!operationMenuContext || !blockMenuService) return [];
@@ -780,23 +829,6 @@ const ReactBlockPlugin: FC<ReactBlockPluginProps> = (props) => {
780829
setDragIndicator(null);
781830
};
782831

783-
const toggleOperationMenu = (context: IBlockMenuRenderContext | null) => {
784-
if (!context) {
785-
setOperationMenuOpen(false);
786-
setOperationMenuContext(null);
787-
return;
788-
}
789-
790-
setOperationMenuOpen((open) => {
791-
const shouldOpen = !(
792-
open && contextRef.current.operationMenuAnchorBlockId === context.blockId
793-
);
794-
contextRef.current.operationMenuAnchorBlockId = shouldOpen ? context.blockId : null;
795-
setOperationMenuContext(shouldOpen ? context : null);
796-
return shouldOpen;
797-
});
798-
};
799-
800832
const handleDragHandlePointerDown = (event: ReactPointerEvent<HTMLElement>) => {
801833
if (!menuContext) return;
802834

@@ -815,20 +847,29 @@ const ReactBlockPlugin: FC<ReactBlockPluginProps> = (props) => {
815847
setDragIndicator,
816848
setOperationMenuContext,
817849
setOperationMenuOpen,
818-
toggleOperationMenu,
819850
});
820851
};
821852

822-
const handleDragHandleClick = () => {
853+
const handleOperationMenuOpenChange = (open: boolean) => {
823854
if (contextRef.current.ignoreNextHandleClick) {
824855
contextRef.current.ignoreNextHandleClick = false;
825856
return;
826857
}
827858

828-
toggleOperationMenu(menuContext);
859+
if (!menuContext) return;
860+
861+
if (open) {
862+
setOperationMenuOpen(true);
863+
setOperationMenuContext(menuContext);
864+
contextRef.current.operationMenuAnchorBlockId = menuContext.blockId;
865+
} else {
866+
setOperationMenuOpen(false);
867+
setOperationMenuContext(null);
868+
contextRef.current.operationMenuAnchorBlockId = null;
869+
}
829870
};
830871

831-
const dropdownItems = useMemo(
872+
const dropdownItems = useMemo<DropdownMenuProps['items']>(
832873
() =>
833874
operationMenus.map((item) => ({
834875
key: item.key,
@@ -864,36 +905,25 @@ const ReactBlockPlugin: FC<ReactBlockPluginProps> = (props) => {
864905
/>
865906
);
866907
})}
867-
<Dropdown
868-
align={{
869-
points: ['tr', 'tl'],
870-
}}
871-
classNames={{
872-
root: OPERATION_MENU_OVERLAY_CLASS,
873-
}}
874-
menu={{ items: dropdownItems }}
875-
onOpenChange={(open) => {
876-
if (!open) {
877-
setOperationMenuOpen(false);
878-
setOperationMenuContext(null);
879-
contextRef.current.operationMenuAnchorBlockId = null;
880-
}
881-
}}
908+
<DropdownMenu
909+
items={dropdownItems}
910+
onOpenChange={handleOperationMenuOpenChange}
882911
open={operationMenuOpen && operationMenuContext?.blockId === menuContext.blockId}
883-
trigger={[]}
912+
placement={'leftTop'}
913+
popupProps={{ className: OPERATION_MENU_OVERLAY_CLASS }}
914+
positionerProps={{ style: { zIndex: 1000 } }}
884915
>
885916
<Button
886917
aria-label={'Block actions and drag'}
887918
className={styles.dragHandle}
888919
data-block-drag-handle={'true'}
889920
icon={<Icon icon={GripVerticalIcon} size={14} />}
890-
onClick={handleDragHandleClick}
891921
onPointerDown={handleDragHandlePointerDown}
892922
size={'small'}
893923
title={'Block actions and drag'}
894924
type={'text'}
895925
/>
896-
</Dropdown>
926+
</DropdownMenu>
897927
</div>
898928
</div>
899929
) : null;
@@ -925,7 +955,7 @@ const ReactBlockPlugin: FC<ReactBlockPluginProps> = (props) => {
925955
/>
926956
)}
927957
</>,
928-
document.body,
958+
appElement ?? document.body,
929959
)}
930960
</>
931961
);

src/plugins/block/react/drag/drag-session.ts

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,6 @@ interface StartBlockDragSessionParams {
3636
setDragIndicator: (value: DragIndicator | null) => void;
3737
setOperationMenuContext: (value: IBlockMenuRenderContext | null) => void;
3838
setOperationMenuOpen: (value: boolean) => void;
39-
toggleOperationMenu: (context: IBlockMenuRenderContext | null) => void;
4039
}
4140

4241
const DRAG_GHOST_OFFSET_X = 14;
@@ -136,9 +135,7 @@ export const startBlockDragSession = ({
136135
setDragIndicator,
137136
setOperationMenuContext,
138137
setOperationMenuOpen,
139-
toggleOperationMenu,
140138
}: StartBlockDragSessionParams) => {
141-
setOperationMenuOpen(false);
142139
let dragGhost: HTMLDivElement | null = null;
143140
let restoreSourceOpacity: (() => void) | null = null;
144141

@@ -264,10 +261,6 @@ export const startBlockDragSession = ({
264261

265262
const onPointerUp = () => {
266263
if (!contextRef.current.dragStarted && !contextRef.current.dragMoved) {
267-
toggleOperationMenu(menuContext);
268-
// Pointerup may be followed by click; consume it to avoid immediate double toggle.
269-
contextRef.current.ignoreNextHandleClick = true;
270-
271264
contextRef.current.draggingSource = null;
272265
contextRef.current.dragPointerY = null;
273266
contextRef.current.dragBlocks = [];

src/plugins/block/react/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export type { BlockDragTarget } from './core/types';
22
export { default as ReactBlockPlugin, type ReactBlockPluginProps } from './ReactBlockPlugin';
3+
export { ANCHOR_PADDING_CSS_VAR, DEFAULT_BLOCK_ANCHOR_PADDING } from './style';

src/plugins/block/react/style.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
import { createStaticStyles } from 'antd-style';
22

3+
export const ANCHOR_PADDING_CSS_VAR = '--lobe-block-anchor-padding';
4+
5+
/**
6+
* Default inline padding (px) reserved on the editor root so the floating
7+
* block menu / drag handle has room to render. Exported for consumers that
8+
* need to align surrounding chrome (e.g. a title section above the editor)
9+
* with the editor's content edge.
10+
*/
11+
export const DEFAULT_BLOCK_ANCHOR_PADDING = 54;
12+
313
export const styles = createStaticStyles(({ css, cssVar }) => ({
414
dragHandle: css`
515
cursor: grab !important;
@@ -59,6 +69,6 @@ export const styles = createStaticStyles(({ css, cssVar }) => ({
5969
}
6070
`,
6171
root: css`
62-
padding-inline: 54px 54px;
72+
padding-inline: var(${ANCHOR_PADDING_CSS_VAR}, ${DEFAULT_BLOCK_ANCHOR_PADDING}px);
6373
`,
6474
}));

0 commit comments

Comments
 (0)