fix(core): fix menu bugs (#8074)
This commit is contained in:
@@ -248,7 +248,7 @@ const config = {
|
|||||||
'warn',
|
'warn',
|
||||||
{
|
{
|
||||||
additionalHooks:
|
additionalHooks:
|
||||||
'(useAsyncCallback|useCatchEventCallback|useDraggable|useDropTarget)',
|
'(useAsyncCallback|useCatchEventCallback|useDraggable|useDropTarget|useRefEffect)',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
export { useAutoFocus, useAutoSelect } from './focus-and-select';
|
export { useAutoFocus, useAutoSelect } from './focus-and-select';
|
||||||
|
export { useRefEffect } from './use-ref-effect';
|
||||||
|
|||||||
92
packages/frontend/component/src/hooks/use-ref-effect.ts
Normal file
92
packages/frontend/component/src/hooks/use-ref-effect.ts
Normal 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_;
|
||||||
|
};
|
||||||
@@ -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,
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user