Skip to content

Commit 38f3b57

Browse files
committed
fix(YfmTable): fixed position of floating plus buttons when table is scrolled horizontally
1 parent 7bdb5c3 commit 38f3b57

File tree

4 files changed

+189
-92
lines changed

4 files changed

+189
-92
lines changed

src/extensions/yfm/YfmTable/plugins/YfmTableControls/components/FloatingPlusButton/FloatingPlusButton.tsx

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import {useState} from 'react';
1+
import {forwardRef, useState} from 'react';
22

3-
import {FloatingPopup, type FloatingPopupProps} from '../FloatingPopup';
3+
import {FloatingPopup, type FloatingPopupProps, type FloatingPopupRef} from '../FloatingPopup';
44

55
import {InsertCursor, type InsertCursorProps} from './InsertCursor';
66
import {PlusButton, type PlusButtonProps} from './PlusButton';
@@ -18,18 +18,23 @@ const offsetByType: Record<InsertCursorProps['type'], FloatingPopupProps['offset
1818
column: {alignmentAxis: -10},
1919
};
2020

21-
export type FloatingPlusButtonProps = Pick<PlusButtonProps, 'onClick'> &
21+
export type FloatingPlusButtonRef = FloatingPopupRef & {};
22+
23+
export type FloatingPlusButtonProps = Pick<FloatingPopupProps, 'floatingStyles'> &
24+
Pick<PlusButtonProps, 'onClick'> &
2225
Pick<InsertCursorProps, 'type' | 'anchor'>;
2326

24-
export const FloatingPlusButton: React.FC<FloatingPlusButtonProps> =
25-
function YfmTableFloatingPlusButton({anchor, type, ...btnProps}) {
27+
export const FloatingPlusButton = forwardRef<FloatingPlusButtonRef, FloatingPlusButtonProps>(
28+
function YfmTableFloatingPlusButton({anchor, type, floatingStyles, ...btnProps}, ref) {
2629
const [hovered, setHovered] = useState(false);
2730

2831
return (
2932
<>
3033
<FloatingPopup
3134
open
35+
ref={ref}
3236
anchorElement={anchor}
37+
floatingStyles={floatingStyles}
3338
placement={placementByType[type]}
3439
offset={offsetByType[type]}
3540
style={styles}
@@ -43,4 +48,5 @@ export const FloatingPlusButton: React.FC<FloatingPlusButtonProps> =
4348
{hovered && <InsertCursor anchor={anchor} type={type} />}
4449
</>
4550
);
46-
};
51+
},
52+
);

src/extensions/yfm/YfmTable/plugins/YfmTableControls/components/FloatingPlusControl/FloatingPlusControl.tsx

Lines changed: 63 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,20 @@
1-
import {memo, useMemo} from 'react';
1+
import {memo, useEffect, useMemo, useRef, useState} from 'react';
22

33
// eslint-disable-next-line import/no-extraneous-dependencies
44
import type {VirtualElement} from '@floating-ui/dom';
5+
import {useEffectOnce} from 'react-use';
56

6-
import {FloatingPlusButton, type FloatingPlusButtonProps} from '../FloatingPlusButton';
7+
import {useRafThrottle} from '../../hooks/use-raf-throttle';
8+
import {
9+
FloatingPlusButton,
10+
type FloatingPlusButtonProps,
11+
type FloatingPlusButtonRef,
12+
} from '../FloatingPlusButton';
13+
14+
type ControlType = FloatingPlusButtonProps['type'];
715

816
export type FloatingPlusControlProps = {
9-
type: FloatingPlusButtonProps['type'];
17+
type: ControlType;
1018
index: number;
1119
cellElem: Element;
1220
tableElem: Element;
@@ -48,6 +56,57 @@ export const FloatingPlusControl = memo<FloatingPlusControlProps>(
4856
[cellElem, tableElem, type],
4957
);
5058

51-
return <FloatingPlusButton anchor={anchor} type={type} onClick={() => onClick(index)} />;
59+
const [visible, setVisible] = useState(() => shouldBeVisible(type, cellElem, tableElem));
60+
const buttonRef = useRef<FloatingPlusButtonRef>(null);
61+
62+
const updateVisibility = () => {
63+
const newVisible = shouldBeVisible(type, cellElem, tableElem);
64+
if (visible !== newVisible) setVisible(newVisible);
65+
};
66+
67+
const onChange = useRafThrottle(() => {
68+
buttonRef.current?.forceUpdate();
69+
updateVisibility();
70+
});
71+
72+
// Update after first render
73+
useEffectOnce(updateVisibility);
74+
75+
useEffect(() => {
76+
if (type !== 'column') return undefined;
77+
78+
const observer = new ResizeObserver(onChange);
79+
observer.observe(tableElem);
80+
tableElem.addEventListener('scroll', onChange);
81+
82+
return () => {
83+
observer.unobserve(tableElem);
84+
tableElem.removeEventListener('scroll', onChange);
85+
};
86+
}, [tableElem, onChange, type]);
87+
88+
return (
89+
<FloatingPlusButton
90+
ref={buttonRef}
91+
anchor={anchor}
92+
type={type}
93+
onClick={() => onClick(index)}
94+
floatingStyles={visible ? undefined : {display: 'none'}}
95+
/>
96+
);
5297
},
5398
);
99+
100+
function shouldBeVisible(type: ControlType, cellElem: Element, tableElem: Element): boolean {
101+
if (type !== 'column') return true;
102+
103+
const THRESHOLD = 4; // px
104+
105+
const cellRect = cellElem.getBoundingClientRect();
106+
const tableRect = tableElem.getBoundingClientRect();
107+
108+
return (
109+
tableRect.left - cellRect.right <= THRESHOLD &&
110+
cellRect.right - tableRect.right <= THRESHOLD
111+
);
112+
}

src/extensions/yfm/YfmTable/plugins/YfmTableControls/components/FloatingPopup/FloatingPopup.tsx

Lines changed: 89 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
/// This fork does not use floating focus manager
44
///
55

6-
import {useEffect, useMemo} from 'react';
6+
import {forwardRef, useEffect, useImperativeHandle, useMemo} from 'react';
77

88
// eslint-disable-next-line import/no-extraneous-dependencies
99
import {
@@ -27,6 +27,12 @@ import './FloatingPopup.scss';
2727

2828
const b = cn('yfm-table-floating-popup');
2929

30+
export type {ReferenceType};
31+
32+
export type FloatingPopupRef = {
33+
forceUpdate: () => void;
34+
};
35+
3036
export type FloatingPopupProps = QAProps &
3137
DOMProps & {
3238
open?: boolean;
@@ -38,87 +44,88 @@ export type FloatingPopupProps = QAProps &
3844
floatingStyles?: React.CSSProperties;
3945
};
4046

41-
export const FloatingPopup: React.FC<FloatingPopupProps> = function YfmTableFloatingPopup(
42-
props: FloatingPopupProps,
43-
) {
44-
const {
45-
anchorElement,
46-
zIndex = 1000,
47-
style,
48-
className,
49-
qa,
50-
children,
51-
floatingStyles: floatingStylesFromProps,
52-
} = props;
53-
54-
const nodeId = useFloatingNodeId();
55-
const parentId = useFloatingParentNodeId();
56-
57-
const middleware = useMemo(() => [offsetMiddleware(props.offset)], [props.offset]);
58-
59-
const {
60-
refs,
61-
elements,
62-
floatingStyles,
63-
placement,
64-
context: {open},
65-
update,
66-
} = useFloating({
67-
nodeId,
68-
open: props.open,
69-
placement: props.placement,
70-
middleware,
71-
});
72-
73-
const {getFloatingProps} = useInteractions();
74-
75-
useEffect(() => {
76-
if (anchorElement !== undefined && anchorElement !== refs.reference.current) {
77-
refs.setReference(anchorElement);
78-
}
79-
}, [anchorElement, refs]);
80-
81-
useEffect(() => {
82-
if (elements.reference && elements.floating) {
83-
return autoUpdate(elements.reference, elements.floating, update);
84-
}
85-
return undefined;
86-
}, [elements, update]);
87-
88-
function wrapper(node: JSX.Element) {
89-
if (parentId === null) {
90-
return <FloatingTree>{node}</FloatingTree>;
47+
export const FloatingPopup = forwardRef<FloatingPopupRef, FloatingPopupProps>(
48+
function YfmTableFloatingPopup(props, ref) {
49+
const {
50+
anchorElement,
51+
zIndex = 1000,
52+
style,
53+
className,
54+
qa,
55+
children,
56+
floatingStyles: floatingStylesFromProps,
57+
} = props;
58+
59+
const nodeId = useFloatingNodeId();
60+
const parentId = useFloatingParentNodeId();
61+
62+
const middleware = useMemo(() => [offsetMiddleware(props.offset)], [props.offset]);
63+
64+
const {
65+
refs,
66+
floatingStyles,
67+
placement,
68+
context: {open},
69+
update,
70+
} = useFloating({
71+
nodeId,
72+
open: props.open,
73+
placement: props.placement,
74+
middleware,
75+
whileElementsMounted: autoUpdate,
76+
});
77+
78+
useImperativeHandle<FloatingPopupRef, FloatingPopupRef>(
79+
ref,
80+
() => ({
81+
forceUpdate: update,
82+
}),
83+
[update],
84+
);
85+
86+
const {getFloatingProps} = useInteractions();
87+
88+
useEffect(() => {
89+
if (anchorElement !== undefined && anchorElement !== refs.reference.current) {
90+
refs.setReference(anchorElement);
91+
}
92+
}, [anchorElement, refs]);
93+
94+
function wrapper(node: JSX.Element) {
95+
if (parentId === null) {
96+
return <FloatingTree>{node}</FloatingTree>;
97+
}
98+
99+
return node;
91100
}
92101

93-
return node;
94-
}
95-
96-
return wrapper(
97-
<FloatingNode id={nodeId}>
98-
{open ? (
99-
<Portal>
100-
<div
101-
ref={refs.setFloating}
102-
style={{
103-
position: 'absolute',
104-
top: 0,
105-
left: 0,
106-
zIndex,
107-
width: 'max-content',
108-
pointerEvents: 'auto',
109-
outline: 'none',
110-
...floatingStylesFromProps,
111-
...floatingStyles,
112-
}}
113-
data-floating-ui-placement={placement}
114-
{...getFloatingProps()}
115-
>
116-
<div className={b(null, className)} style={style} data-qa={qa}>
117-
{children}
102+
return wrapper(
103+
<FloatingNode id={nodeId}>
104+
{open ? (
105+
<Portal>
106+
<div
107+
ref={refs.setFloating}
108+
style={{
109+
position: 'absolute',
110+
top: 0,
111+
left: 0,
112+
zIndex,
113+
width: 'max-content',
114+
pointerEvents: 'auto',
115+
outline: 'none',
116+
...floatingStylesFromProps,
117+
...floatingStyles,
118+
}}
119+
data-floating-ui-placement={placement}
120+
{...getFloatingProps()}
121+
>
122+
<div className={b(null, className)} style={style} data-qa={qa}>
123+
{children}
124+
</div>
118125
</div>
119-
</div>
120-
</Portal>
121-
) : null}
122-
</FloatingNode>,
123-
);
124-
};
126+
</Portal>
127+
) : null}
128+
</FloatingNode>,
129+
);
130+
},
131+
);
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import {useCallback, useEffect, useRef} from 'react';
2+
3+
import {useLatest} from 'react-use';
4+
5+
export function useRafThrottle(fn: () => void): () => void {
6+
const refFn = useLatest(fn);
7+
const refHandle = useRef<number | null>(null);
8+
9+
useEffect(() => {
10+
return () => {
11+
if (refHandle.current !== null) {
12+
cancelAnimationFrame(refHandle.current);
13+
}
14+
};
15+
}, [refFn]);
16+
17+
return useCallback(() => {
18+
if (refHandle.current === null) {
19+
refHandle.current = requestAnimationFrame(() => {
20+
refHandle.current = null;
21+
refFn.current();
22+
});
23+
}
24+
}, [refFn]);
25+
}

0 commit comments

Comments
 (0)