fix(app): show the format toolbar on double-click selections (#2566)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-07 00:13:30 +02:00
1 parent 977b6767cd
commit 10ba2ca896
4 files changed
+78 -7

No files matched your search

@@ -0,0 +1,56 @@
// @vitest-environment happy-dom
// SPDX-License-Identifier: AGPL-3.0-or-later
import {useAntiShiftFloating} from '@app/features/app/hooks/useAntiShiftFloating';
import {act, createElement, useLayoutEffect, useState} from 'react';
import {createRoot, type Root} from 'react-dom/client';
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
let host: HTMLDivElement;
let root: Root;
let target: HTMLDivElement;
function PortalLikeFloating({onReady}: {onReady: (isReady: boolean) => void}) {
const {setFloating, state} = useAntiShiftFloating(target, true, {placement: 'top'});
const [mounted, setMounted] = useState(false);
useLayoutEffect(() => {
setMounted(true);
}, []);
onReady(state.isReady);
return mounted ? createElement('div', {ref: setFloating, 'data-testid': 'floating'}) : null;
}
async function settle(): Promise<void> {
for (let attempt = 0; attempt < 20; attempt += 1) {
await act(async () => {
await new Promise((resolve) => requestAnimationFrame(() => resolve(null)));
await Promise.resolve();
});
}
}
beforeEach(() => {
host = document.createElement('div');
target = document.createElement('div');
document.body.append(host, target);
root = createRoot(host);
});
afterEach(() => {
act(() => {
root.unmount();
});
document.body.replaceChildren();
});
describe('useAntiShiftFloating', () => {
it('positions a floating element that mounts after the first commit', async () => {
const readyStates: Array<boolean> = [];
act(() => {
root.render(createElement(PortalLikeFloating, {onReady: (isReady) => readyStates.push(isReady)}));
});
await settle();
expect(readyStates.at(-1)).toBe(true);
expect(host.querySelector<HTMLElement>('[data-testid="floating"]')?.style.visibility).toBe('visible');
});
});
@@ -279,6 +279,11 @@ export function useAntiShiftFloating(
constrainHeight = false, constrainHeight = false,
} = options; } = options;
const floatingRef = useRef<HTMLElement>(null); const floatingRef = useRef<HTMLElement>(null);
const [floatingElement, setFloatingElement] = useState<HTMLElement | null>(null);
const setFloating = useCallback((element: HTMLElement | null) => {
floatingRef.current = element;
setFloatingElement(element);
}, []);
const [state, setState] = useState<FloatingState>(() => { const [state, setState] = useState<FloatingState>(() => {
const {x, y} = target ? getInitialGuess(target, placement, offsetMainAxis, offsetCrossAxis) : {x: -9999, y: -9999}; const {x, y} = target ? getInitialGuess(target, placement, offsetMainAxis, offsetCrossAxis) : {x: -9999, y: -9999};
return {x, y, isReady: false, offsetX: 0, offsetY: 0, placement}; return {x, y, isReady: false, offsetX: 0, offsetY: 0, placement};
@@ -481,7 +486,7 @@ export function useAntiShiftFloating(
}; };
}, []); }, []);
useLayoutEffect(() => { useLayoutEffect(() => {
if (!enabled || target == null || floatingRef.current == null) { if (!enabled || target == null || floatingElement == null) {
setState((prev) => ({...prev, isReady: false, offsetX: 0, offsetY: 0})); setState((prev) => ({...prev, isReady: false, offsetX: 0, offsetY: 0}));
return; return;
} }
@@ -489,7 +494,7 @@ export function useAntiShiftFloating(
setState((prev) => ({...prev, isReady: false, offsetX: 0, offsetY: 0})); setState((prev) => ({...prev, isReady: false, offsetX: 0, offsetY: 0}));
return; return;
} }
const floating = floatingRef.current; const floating = floatingElement;
updatePosition(); updatePosition();
if (shouldAutoUpdate) { if (shouldAutoUpdate) {
const cleanupCallbacks = [ const cleanupCallbacks = [
@@ -502,7 +507,7 @@ export function useAntiShiftFloating(
} }
}; };
} else if (shouldObserveFloatingResize) { } else if (shouldObserveFloatingResize) {
cleanupRef.current = observeFloatingResize(floatingRef.current, updatePosition); cleanupRef.current = observeFloatingResize(floating, updatePosition);
} }
return () => { return () => {
positionRevisionRef.current += 1; positionRevisionRef.current += 1;
@@ -519,9 +524,18 @@ export function useAntiShiftFloating(
} }
setState((prev) => ({...prev, isReady: false, offsetX: 0, offsetY: 0})); setState((prev) => ({...prev, isReady: false, offsetX: 0, offsetY: 0}));
}; };
}, [enabled, target, shouldAutoUpdate, shouldObserveFloatingResize, updatePosition, updatePositionNow]); }, [
enabled,
target,
floatingElement,
shouldAutoUpdate,
shouldObserveFloatingResize,
updatePosition,
updatePositionNow,
]);
return { return {
ref: floatingRef, ref: floatingRef,
setFloating,
state, state,
style: { style: {
position: 'fixed' as const, position: 'fixed' as const,
@@ -271,7 +271,7 @@ function SelectionToolbarSurface({
}), }),
[editor], [editor],
); );
const {ref, state, style, updatePosition} = useAntiShiftFloating(virtualReference, true, { const {setFloating, state, style, updatePosition} = useAntiShiftFloating(virtualReference, true, {
placement: 'top', placement: 'top',
offsetMainAxis: 8, offsetMainAxis: 8,
shouldAutoUpdate: true, shouldAutoUpdate: true,
@@ -381,7 +381,7 @@ function SelectionToolbarSurface({
> >
<flx-lexical-selection-formatting-toolbar <flx-lexical-selection-formatting-toolbar
ref={(element) => { ref={(element) => {
(ref as React.MutableRefObject<HTMLElement | null>).current = element; setFloating(element);
toolbarRef.current = element; toolbarRef.current = element;
}} }}
className={flxElementClassName(styles.toolbar)} className={flxElementClassName(styles.toolbar)}
@@ -186,6 +186,7 @@ const PopoutItem: React.FC<PopoutItemProps> = observer(
); );
const { const {
ref: popoutRef, ref: popoutRef,
setFloating,
state, state,
style, style,
beginManualPositioning, beginManualPositioning,
@@ -206,7 +207,7 @@ const PopoutItem: React.FC<PopoutItemProps> = observer(
useLayoutEffect(() => { useLayoutEffect(() => {
focusRefs.setReference(target); focusRefs.setReference(target);
}, [focusRefs, target]); }, [focusRefs, target]);
const mergedPopoutRef = useMergeRefs([popoutRef, focusRefs.setFloating]); const mergedPopoutRef = useMergeRefs([setFloating, focusRefs.setFloating]);
const prefersReducedMotion = Accessibility.useReducedMotion; const prefersReducedMotion = Accessibility.useReducedMotion;
const [isVisible, setIsVisible] = useState(true); const [isVisible, setIsVisible] = useState(true);
const [targetInDOM, setTargetInDOM] = useState(() => ownerDocument.contains(target)); const [targetInDOM, setTargetInDOM] = useState(() => ownerDocument.contains(target));