fix(core): fix menu bugs (#8074)

This commit is contained in:
EYHN
2024-09-04 07:19:08 +00:00
parent 01e6370dd2
commit 51f3566bec
12 changed files with 188 additions and 79 deletions

View File

@@ -248,7 +248,7 @@ const config = {
'warn', 'warn',
{ {
additionalHooks: additionalHooks:
'(useAsyncCallback|useCatchEventCallback|useDraggable|useDropTarget)', '(useAsyncCallback|useCatchEventCallback|useDraggable|useDropTarget|useRefEffect)',
}, },
], ],
}, },

View File

@@ -25,7 +25,9 @@ export const useAutoSelect = <T extends HTMLInputElement = HTMLInputElement>(
useLayoutEffect(() => { useLayoutEffect(() => {
if (ref.current && autoSelect) { if (ref.current && autoSelect) {
ref.current?.select(); setTimeout(() => {
ref.current?.select();
}, 0);
} }
}, [autoSelect, ref]); }, [autoSelect, ref]);

View File

@@ -1 +1,2 @@
export { useAutoFocus, useAutoSelect } from './focus-and-select'; export { useAutoFocus, useAutoSelect } from './focus-and-select';
export { useRefEffect } from './use-ref-effect';

View File

@@ -0,0 +1,92 @@
/**
* modified version of useRefEffect from https://github.com/jantimon/react-use-ref-effect/blob/master/src/index.tsx
*/
import { useDebugValue, useEffect, useState } from 'react';
// internalRef is used as a reference and therefore save to be used inside an effect
/* eslint-disable react-hooks/exhaustive-deps */
// the `process.env.NODE_ENV !== 'production'` condition is resolved by the build tool
/* eslint-disable react-hooks/rules-of-hooks */
const noop: (...args: any[]) => any = () => {};
/**
* `useRefEffect` returns a mutable ref object to be connected with a DOM Node.
*
* The returned object will persist for the full lifetime of the component.
* Accepts a function that contains imperative, possibly effectful code.
*
* @param effect Imperative function that can return a cleanup function
* @param deps If present, effect will only activate if the ref or the values in the list change.
*/
export const useRefEffect = <T>(
effect: (element: T) => void | (() => void),
dependencies: any[] = []
): React.RefCallback<T> & React.MutableRefObject<T | null> => {
// Use the initial state as mutable reference
const internalRef = useState(() => {
let currentValue = null as T | null;
let cleanupPreviousEffect = noop as () => void;
let currentDeps: any[] | undefined;
/**
* React.RefCallback
*/
const setRefValue = (newElement: T | null) => {
// Only execute if dependencies or element changed:
if (
internalRef.dependencies_ !== currentDeps ||
currentValue !== newElement
) {
currentValue = newElement;
currentDeps = internalRef.dependencies_;
internalRef.cleanup_();
if (newElement) {
cleanupPreviousEffect = internalRef.effect_(newElement) || noop;
}
}
};
return {
/** Execute the effects cleanup function */
cleanup_: () => {
cleanupPreviousEffect();
cleanupPreviousEffect = noop;
},
ref_: Object.defineProperty(setRefValue, 'current', {
get: () => currentValue,
set: setRefValue,
}),
} as {
cleanup_: () => void;
ref_: React.RefCallback<T> & React.MutableRefObject<T | null>;
// Those two properties will be set immediately after initialisation
effect_: typeof effect;
dependencies_: typeof dependencies;
};
})[0];
// Show the current ref value in development
// in react dev tools
if (process.env.NODE_ENV !== 'production') {
useDebugValue(internalRef.ref_.current);
}
// Keep a ref to the latest callback
internalRef.effect_ = effect;
useEffect(
() => {
// Run effect if dependencies change
internalRef.ref_(internalRef.ref_.current);
return () => {
if (internalRef.dependencies_ === dependencies) {
internalRef.cleanup_();
internalRef.dependencies_ = [];
}
};
}, // Keep a ref to the latest dependencies
(internalRef.dependencies_ = dependencies)
);
return internalRef.ref_;
};

View File

@@ -57,7 +57,6 @@ Basic.args = {
editable: true, editable: true,
placeholder: 'Untitled', placeholder: 'Untitled',
trigger: 'doubleClick', trigger: 'doubleClick',
autoSelect: true,
}; };
export const CustomizeText: StoryFn<typeof InlineEdit> = export const CustomizeText: StoryFn<typeof InlineEdit> =
@@ -104,7 +103,6 @@ export const TriggerEdit: StoryFn<typeof InlineEdit> = args => {
TriggerEdit.args = { TriggerEdit.args = {
value: 'Trigger edit mode in parent component by `handleRef`', value: 'Trigger edit mode in parent component by `handleRef`',
editable: true, editable: true,
autoSelect: true,
}; };
export const UpdateValue: StoryFn<typeof InlineEdit> = args => { export const UpdateValue: StoryFn<typeof InlineEdit> = args => {
@@ -137,5 +135,4 @@ export const UpdateValue: StoryFn<typeof InlineEdit> = args => {
UpdateValue.args = { UpdateValue.args = {
value: 'Update value in parent component by `value`', value: 'Update value in parent component by `value`',
editable: true, editable: true,
autoSelect: true,
}; };

View File

@@ -46,11 +46,6 @@ export interface InlineEditProps
*/ */
trigger?: 'click' | 'doubleClick'; trigger?: 'click' | 'doubleClick';
/**
* whether to auto select all text when trigger edit
*/
autoSelect?: boolean;
/** /**
* Placeholder when value is empty * Placeholder when value is empty
*/ */
@@ -79,7 +74,6 @@ export const InlineEdit = ({
className, className,
style, style,
trigger = 'doubleClick', trigger = 'doubleClick',
autoSelect,
onInput, onInput,
onChange, onChange,
@@ -104,11 +98,7 @@ export const InlineEdit = ({
const triggerEdit = useCallback(() => { const triggerEdit = useCallback(() => {
if (!editable) return; if (!editable) return;
setEditing(true); setEditing(true);
setTimeout(() => { }, [editable]);
inputRef.current?.focus();
autoSelect && inputRef.current?.select();
}, 0);
}, [autoSelect, editable]);
const onDoubleClick = useCallback(() => { const onDoubleClick = useCallback(() => {
if (trigger !== 'doubleClick') return; if (trigger !== 'doubleClick') return;
@@ -208,7 +198,7 @@ export const InlineEdit = ({
</div> </div>
{/* actual input */} {/* actual input */}
{ {editing && (
<Input <Input
ref={inputRef} ref={inputRef}
className={styles.inlineEditInput} className={styles.inlineEditInput}
@@ -220,9 +210,11 @@ export const InlineEdit = ({
style={inputWrapperInheritsStyles} style={inputWrapperInheritsStyles}
inputStyle={inputInheritsStyles} inputStyle={inputInheritsStyles}
onBlur={onBlur} onBlur={onBlur}
autoFocus
autoSelect
{...inputAttrs} {...inputAttrs}
/> />
} )}
</div> </div>
); );
}; };

View File

@@ -1,20 +1,24 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useState } from 'react';
import { useRefEffect } from '../../../hooks';
export const useMenuContentController = ({ export const useMenuContentController = ({
onOpenChange, onOpenChange,
side, side,
defaultOpen, defaultOpen,
sideOffset, sideOffset,
open: controlledOpen,
}: { }: {
defaultOpen?: boolean; defaultOpen?: boolean;
side?: 'top' | 'bottom' | 'left' | 'right'; side?: 'top' | 'bottom' | 'left' | 'right';
onOpenChange?: (open: boolean) => void; onOpenChange?: (open: boolean) => void;
open?: boolean;
sideOffset?: number; sideOffset?: number;
} = {}) => { } = {}) => {
const [open, setOpen] = useState(defaultOpen ?? false); const [open, setOpen] = useState(defaultOpen ?? false);
const actualOpen = controlledOpen ?? open;
const contentSide = side ?? 'bottom'; const contentSide = side ?? 'bottom';
const [contentOffset, setContentOffset] = useState<number>(0); const [contentOffset, setContentOffset] = useState<number>(0);
const contentRef = useRef<HTMLDivElement>(null);
const handleOpenChange = useCallback( const handleOpenChange = useCallback(
(open: boolean) => { (open: boolean) => {
@@ -23,65 +27,69 @@ export const useMenuContentController = ({
}, },
[onOpenChange] [onOpenChange]
); );
useEffect(() => { const contentRef = useRefEffect<HTMLDivElement>(
if (!open || !contentRef.current) return; contentElement => {
if (!actualOpen) return;
const wrapperElement = contentRef.current.parentNode as HTMLDivElement; const wrapperElement = contentElement.parentNode as HTMLDivElement;
const updateContentOffset = () => { const updateContentOffset = () => {
if (!contentRef.current) return; if (!contentElement) return;
const contentRect = wrapperElement.getBoundingClientRect(); const contentRect = wrapperElement.getBoundingClientRect();
if (contentSide === 'bottom') { if (contentSide === 'bottom') {
setContentOffset(prev => { setContentOffset(prev => {
const viewportHeight = window.innerHeight; const viewportHeight = window.innerHeight;
const newOffset = Math.min( const newOffset = Math.min(
viewportHeight - (contentRect.bottom - prev), viewportHeight - (contentRect.bottom - prev),
0 0
); );
return newOffset; return newOffset;
}); });
} else if (contentSide === 'top') { } else if (contentSide === 'top') {
setContentOffset(prev => { setContentOffset(prev => {
const newOffset = Math.max(contentRect.top - prev, 0); const newOffset = Math.min(contentRect.top + prev, 0);
return newOffset; return newOffset;
}); });
} else if (contentSide === 'left') { } else if (contentSide === 'left') {
setContentOffset(prev => { setContentOffset(prev => {
const newOffset = Math.max(contentRect.left - prev, 0); const newOffset = Math.min(contentRect.left + prev, 0);
return newOffset; return newOffset;
}); });
} else if (contentSide === 'right') { } else if (contentSide === 'right') {
setContentOffset(prev => { setContentOffset(prev => {
const viewportWidth = window.innerWidth; const viewportWidth = window.innerWidth;
const newOffset = Math.min( const newOffset = Math.min(
viewportWidth - (contentRect.right - prev), viewportWidth - (contentRect.right - prev),
0 0
); );
return newOffset; return newOffset;
}); });
} }
}; };
let animationFrame: number = 0; let animationFrame: number = 0;
const requestUpdateContentOffset = () => { const requestUpdateContentOffset = () => {
cancelAnimationFrame(animationFrame); cancelAnimationFrame(animationFrame);
animationFrame = requestAnimationFrame(updateContentOffset); animationFrame = requestAnimationFrame(updateContentOffset);
}; };
const observer = new ResizeObserver(requestUpdateContentOffset); const observer = new ResizeObserver(requestUpdateContentOffset);
observer.observe(wrapperElement); observer.observe(wrapperElement);
window.addEventListener('resize', requestUpdateContentOffset); window.addEventListener('resize', requestUpdateContentOffset);
requestUpdateContentOffset(); requestUpdateContentOffset();
return () => { return () => {
observer.disconnect(); observer.disconnect();
window.removeEventListener('resize', requestUpdateContentOffset); window.removeEventListener('resize', requestUpdateContentOffset);
cancelAnimationFrame(animationFrame); cancelAnimationFrame(animationFrame);
}; };
}, [contentSide, open]); },
[actualOpen, contentSide]
);
return { return {
handleOpenChange, handleOpenChange,
contentSide, contentSide,
contentOffset: (sideOffset ?? 0) + contentOffset, contentOffset: (sideOffset ?? 0) + contentOffset,
contentRef, contentRef,
open: actualOpen,
}; };
}; };

View File

@@ -10,7 +10,13 @@ export const DesktopMenu = ({
children, children,
items, items,
portalOptions, portalOptions,
rootOptions: { onOpenChange, defaultOpen, modal, ...rootOptions } = {}, rootOptions: {
onOpenChange,
defaultOpen,
modal,
open: rootOpen,
...rootOptions
} = {},
contentOptions: { contentOptions: {
className = '', className = '',
style: contentStyle = {}, style: contentStyle = {},
@@ -19,8 +25,9 @@ export const DesktopMenu = ({
...otherContentOptions ...otherContentOptions
} = {}, } = {},
}: MenuProps) => { }: MenuProps) => {
const { handleOpenChange, contentSide, contentOffset, contentRef } = const { handleOpenChange, contentSide, contentOffset, contentRef, open } =
useMenuContentController({ useMenuContentController({
open: rootOpen,
defaultOpen, defaultOpen,
onOpenChange, onOpenChange,
side, side,
@@ -31,6 +38,7 @@ export const DesktopMenu = ({
onOpenChange={handleOpenChange} onOpenChange={handleOpenChange}
defaultOpen={defaultOpen} defaultOpen={defaultOpen}
modal={modal ?? false} modal={modal ?? false}
open={open}
{...rootOptions} {...rootOptions}
> >
<DropdownMenu.Trigger <DropdownMenu.Trigger

View File

@@ -12,7 +12,12 @@ export const DesktopMenuSub = ({
children: propsChildren, children: propsChildren,
items, items,
portalOptions, portalOptions,
subOptions: { defaultOpen, onOpenChange, ...otherSubOptions } = {}, subOptions: {
defaultOpen,
onOpenChange,
open: rootOpen,
...otherSubOptions
} = {},
triggerOptions, triggerOptions,
subContentOptions: { subContentOptions: {
className: subContentClassName = '', className: subContentClassName = '',
@@ -27,9 +32,10 @@ export const DesktopMenuSub = ({
suffixIcon: <ArrowRightSmallIcon />, suffixIcon: <ArrowRightSmallIcon />,
}); });
const { handleOpenChange, contentOffset, contentRef } = const { handleOpenChange, contentOffset, contentRef, open } =
useMenuContentController({ useMenuContentController({
defaultOpen, defaultOpen,
open: rootOpen,
onOpenChange, onOpenChange,
side: 'right', side: 'right',
sideOffset: (sideOffset ?? 0) + 12, sideOffset: (sideOffset ?? 0) + 12,
@@ -39,6 +45,7 @@ export const DesktopMenuSub = ({
<DropdownMenu.Sub <DropdownMenu.Sub
defaultOpen={defaultOpen} defaultOpen={defaultOpen}
onOpenChange={handleOpenChange} onOpenChange={handleOpenChange}
open={open}
{...otherSubOptions} {...otherSubOptions}
> >
<DropdownMenu.SubTrigger className={className} {...otherProps}> <DropdownMenu.SubTrigger className={className} {...otherProps}>

View File

@@ -43,7 +43,6 @@ export const BlocksuiteHeaderTitle = (props: BlockSuiteHeaderTitleProps) => {
return ( return (
<InlineEdit <InlineEdit
className={clsx(styles.title, props.className)} className={clsx(styles.title, props.className)}
autoSelect
value={docTitle} value={docTitle}
onChange={onChange} onChange={onChange}
editable={!isSharedMode} editable={!isSharedMode}

View File

@@ -376,9 +376,9 @@ export const ExplorerTreeNode = ({
</div> </div>
</div> </div>
{renameable && renaming && ( {renameable && (
<RenameModal <RenameModal
open open={!!renaming}
width={sidebarWidth - 32} width={sidebarWidth - 32}
onOpenChange={setRenaming} onOpenChange={setRenaming}
onRename={handleRename} onRename={handleRename}

View File

@@ -116,7 +116,10 @@ export function NormalPageHeader({ page, workspace }: PageHeaderProps) {
useDetailPageHeaderResponsive(containerWidth); useDetailPageHeaderResponsive(containerWidth);
const onRename = useCallback(() => { const onRename = useCallback(() => {
setTimeout(() => titleInputHandleRef.current?.triggerEdit()); setTimeout(
() => titleInputHandleRef.current?.triggerEdit(),
500 /* wait for menu animation end */
);
}, []); }, []);
const title = useDocCollectionPageTitle(workspace.docCollection, page?.id); const title = useDocCollectionPageTitle(workspace.docCollection, page?.id);