Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions src/plugins/slash/react/ReactSlashPlugin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,23 @@ import SlashMenu from './components/SlashMenu';
import type { ReactSlashOptionProps, ReactSlashPluginProps } from './type';
import { setCancelablePromise } from './utils';

const ReactSlashPlugin: FC<ReactSlashPluginProps> = ({ children, anchorClassName }) => {
const ReactSlashPlugin: FC<ReactSlashPluginProps> = ({
children,
anchorClassName,
getPopupContainer,
placement,
}) => {
const [editor] = useLexicalComposerContext();
const [isOpen, setIsOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [activeKey, setActiveKey] = useState<string | null>(null);
const [resolution, setResolution] = useState<ITriggerContext | null>(null);
const [options, setOptions] = useState<Array<ISlashOption>>([]);
const [dropdownPosition, setDropdownPosition] = useState({ x: 0, y: 0 });
const [dropdownPosition, setDropdownPosition] = useState<{
rect?: DOMRect;
x: number;
y: number;
}>({ x: 0, y: 0 });
const cancelRef = useRef<{
cancel: () => void;
}>({
Expand Down Expand Up @@ -100,7 +109,7 @@ const ReactSlashPlugin: FC<ReactSlashPluginProps> = ({ children, anchorClassName
};
}
const rect = ctx.getRect();
setDropdownPosition({ x: rect.left, y: rect.bottom });
setDropdownPosition({ rect, x: rect.left, y: rect.bottom });
setIsOpen(true);
},
});
Expand Down Expand Up @@ -261,12 +270,14 @@ const ReactSlashPlugin: FC<ReactSlashPluginProps> = ({ children, anchorClassName
activeKey={activeKey}
anchorClassName={anchorClassName}
customRender={CustomRender}
getPopupContainer={getPopupContainer}
loading={loading}
onActiveKeyChange={handleActiveKeyChange}
onClose={close}
onSelect={handleMenuSelect}
open={isOpen}
options={options}
placement={placement}
position={dropdownPosition}
/>
);
Expand Down
132 changes: 100 additions & 32 deletions src/plugins/slash/react/components/DefaultSlashMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,33 +1,100 @@
'use client';

import { Dropdown, type MenuProps } from '@lobehub/ui';
import { type Placement, autoUpdate, flip, offset, shift, useFloating } from '@floating-ui/react';
import { Block, Menu, type MenuProps } from '@lobehub/ui';
import { createStaticStyles } from 'antd-style';
import { type FC, useCallback } from 'react';
import { type FC, useCallback, useEffect, useLayoutEffect, useMemo } from 'react';
import { createPortal } from 'react-dom';

import { ISlashMenuOption } from '../../service/i-slash-service';
import type { SlashMenuProps } from '../type';

const LOBE_THEME_APP_ID = 'lobe-ui-theme-app';

const styles = createStaticStyles(({ css }) => ({
container: css`
overflow-y: auto;
max-height: min(50vh, 400px);

.ant-menu {
min-width: 200px;
}

.ant-menu-item {
display: flex;
align-items: center;
}

.ant-menu-item-active {
background-color: var(--ant-menu-item-hover-bg) !important;
}

.ant-menu-title-content,
.ant-menu-title-content-with-extra {
overflow: visible;
display: inline-flex;
gap: 24px;
justify-content: space-between;

width: 100%;

text-overflow: unset;
}
`,
menu: css`
position: fixed;
z-index: 9999;
width: max-content;
`,
}));

type DefaultSlashMenuProps = Omit<SlashMenuProps, 'customRender' | 'onActiveKeyChange' | 'editor'>;

const DefaultSlashMenu: FC<DefaultSlashMenuProps> = ({
activeKey,
anchorClassName,
getPopupContainer,
loading,
onSelect,
open,
options,
placement: forcePlacement,
position,
// onClose is passed through but not used directly in this component
// eslint-disable-next-line @typescript-eslint/no-unused-vars
onClose: _onClose,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
anchorClassName: _anchorClassName,
}) => {
const resolvedPlacement: Placement = forcePlacement ? `${forcePlacement}-start` : 'top-start';

const middleware = useMemo(
() => [offset(8), ...(!forcePlacement ? [flip()] : []), shift({ padding: 8 })],
[forcePlacement],
);

const { refs, floatingStyles, update } = useFloating({
middleware,
open,
placement: resolvedPlacement,
strategy: 'fixed',
whileElementsMounted: autoUpdate,
});

useLayoutEffect(() => {
if (!position.rect) return;
refs.setPositionReference({
getBoundingClientRect: () => position.rect!,
});
}, [position.rect, refs]);

// Listen to scroll events on the custom container to update position
useEffect(() => {
const container = getPopupContainer?.();
if (!container || !open) return;

const onScroll = () => update();
container.addEventListener('scroll', onScroll, { passive: true });
return () => container.removeEventListener('scroll', onScroll);
}, [getPopupContainer, open, update]);

const handleMenuClick: MenuProps['onClick'] = useCallback(
({ key }: { key: string }) => {
const option = options.find(
Expand All @@ -38,35 +105,36 @@ const DefaultSlashMenu: FC<DefaultSlashMenuProps> = ({
[options, onSelect],
);

return (
<div
className={styles.menu}
style={{
left: position.x,
top: position.y,
}}
>
<Dropdown
menu={{
// @ts-ignore
activeKey: activeKey,
items: loading
? [
{
disabled: true,
key: 'loading',
label: 'Loading...',
},
]
: options,
onClick: handleMenuClick,
}}
open={open}
>
<span className={anchorClassName} />
</Dropdown>
if (!open) return null;

const portalContainer =
getPopupContainer?.() || document.getElementById(LOBE_THEME_APP_ID) || document.body;

const node = (
<div className={styles.menu} ref={refs.setFloating} style={floatingStyles}>
<Block className={styles.container} shadow variant={'outlined'}>
<Menu
// @ts-ignore - activeKey is a valid antd Menu prop passed via ...rest
activeKey={activeKey}
items={
loading
? [
{
disabled: true,
key: 'loading',
label: 'Loading...',
},
]
: options
}
onClick={handleMenuClick}
selectable={false}
/>
</Block>
</div>
);

return createPortal(node, portalContainer);
};

DefaultSlashMenu.displayName = 'DefaultSlashMenu';
Expand Down
4 changes: 4 additions & 0 deletions src/plugins/slash/react/components/SlashMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,13 @@ const SlashMenu: FC<SlashMenuProps> = ({
activeKey,
anchorClassName,
customRender: CustomRender,
getPopupContainer,
loading,
onActiveKeyChange,
onSelect,
open,
options,
placement,
position,
// onClose is passed through but not used directly in this component
// eslint-disable-next-line @typescript-eslint/no-unused-vars
Expand Down Expand Up @@ -52,11 +54,13 @@ const SlashMenu: FC<SlashMenuProps> = ({
<DefaultSlashMenu
activeKey={activeKey}
anchorClassName={anchorClassName}
getPopupContainer={getPopupContainer}
loading={loading}
onClose={onClose}
onSelect={onSelect}
open={open}
options={options}
placement={placement}
position={position}
/>
);
Expand Down
10 changes: 9 additions & 1 deletion src/plugins/slash/react/type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ export interface SlashMenuProps {
anchorClassName?: string;
/** Custom render component if provided */
customRender?: FC<MenuRenderProps>;
/** Custom popup container for portal rendering and scroll tracking */
getPopupContainer?: () => HTMLElement | null;
/** Loading state */
loading: boolean;
/** Callback to set active key */
Expand All @@ -93,13 +95,19 @@ export interface SlashMenuProps {
open: boolean;
/** Available options to display */
options: Array<ISlashOption>;
/** Force menu placement direction, skipping auto-flip detection */
placement?: 'bottom' | 'top';
/** Menu position */
position: { x: number; y: number };
position: { rect?: DOMRect; x: number; y: number };
}

export interface ReactSlashPluginProps {
anchorClassName?: string;
children?:
| (ReactElement<ReactSlashOptionProps> | undefined)
| (ReactElement<ReactSlashOptionProps> | undefined)[];
/** Custom popup container for portal rendering and scroll tracking */
getPopupContainer?: () => HTMLElement | null;
/** Force menu placement direction, skipping auto-flip detection */
placement?: 'bottom' | 'top';
}
13 changes: 11 additions & 2 deletions src/react/Editor/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ const Editor = memo<EditorProps>(
lineEmptyPlaceholder,
plugins = [],
slashOption = {},
slashPlacement,
getPopupContainer,
mentionOption = {},
variant,
onKeyDown,
Expand Down Expand Up @@ -85,7 +87,7 @@ const Editor = memo<EditorProps>(
if (!enableSlash && !enableMention) return null;

return (
<ReactSlashPlugin>
<ReactSlashPlugin getPopupContainer={getPopupContainer} placement={slashPlacement}>
{enableSlash ? (
<ReactSlashOption maxLength={8} trigger="/" {...slashOption} />
) : undefined}
Expand All @@ -94,7 +96,14 @@ const Editor = memo<EditorProps>(
) : undefined}
</ReactSlashPlugin>
);
}, [enableSlash, enableMention, slashOption, restMentionOption]);
}, [
enableSlash,
enableMention,
slashOption,
slashPlacement,
getPopupContainer,
restMentionOption,
]);

return (
<ReactEditor config={config} editor={editor} onInit={onInit}>
Expand Down
4 changes: 4 additions & 0 deletions src/react/Editor/type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ export interface EditorProps
* @default true
*/
enablePasteMarkdown?: boolean;
/** Custom popup container for slash menu portal rendering and scroll tracking */
getPopupContainer?: () => HTMLElement | null;
markdownOption?:
| boolean
| {
Expand All @@ -49,5 +51,7 @@ export interface EditorProps
onTextChange?: (editor: IEditor) => void;
plugins?: EditorPlugin[];
slashOption?: Partial<ReactSlashOptionProps>;
/** Force slash menu placement direction, skipping auto-flip detection */
slashPlacement?: 'bottom' | 'top';
style?: CSSProperties;
}
5 changes: 4 additions & 1 deletion src/react/FloatMenu/FloatMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const FloatMenu: FC<FloatMenuProps> = ({
children,
maxHeight = 'min(50vh, 640px)',
open,
placement = 'top',
styles: customStyles,
classNames,
}) => {
Expand All @@ -23,9 +24,11 @@ const FloatMenu: FC<FloatMenuProps> = ({
if (!parent) return;
if (!open) return;

const rootClassName = placement === 'bottom' ? styles.rootBottom : styles.rootTop;

const node = (
<Flexbox
className={cx(styles.root, classNames?.root)}
className={cx(rootClassName, classNames?.root)}
paddingInline={8}
style={customStyles?.root}
width={'100%'}
Expand Down
10 changes: 9 additions & 1 deletion src/react/FloatMenu/style.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,15 @@ export const styles = createStaticStyles(({ css, cssVar }) => ({
containerWithMaxHeight: css`
/* maxHeight is set via inline style as it's dynamic */
`,
root: css`
rootBottom: css`
position: absolute;
z-index: 9999;
inset-block-start: 100%;
inset-inline-start: 0;

padding-block-start: 8px;
`,
rootTop: css`
position: absolute;
inset-block-start: -8px;
inset-inline-start: 0;
Expand Down
2 changes: 2 additions & 0 deletions src/react/FloatMenu/type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ export interface FloatMenuProps {
getPopupContainer: () => HTMLDivElement | null;
maxHeight?: string | number;
open?: boolean;
/** Menu placement direction: 'top' (default) or 'bottom' */
placement?: 'bottom' | 'top';
style?: CSSProperties;
styles?: {
container?: CSSProperties;
Expand Down
Loading