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,
} = options;
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 {x, y} = target ? getInitialGuess(target, placement, offsetMainAxis, offsetCrossAxis) : {x: -9999, y: -9999};
return {x, y, isReady: false, offsetX: 0, offsetY: 0, placement};
@@ -481,7 +486,7 @@ export function useAntiShiftFloating(
};
}, []);
useLayoutEffect(() => {
if (!enabled || target == null || floatingRef.current == null) {
if (!enabled || target == null || floatingElement == null) {
setState((prev) => ({...prev, isReady: false, offsetX: 0, offsetY: 0}));
return;
}
@@ -489,7 +494,7 @@ export function useAntiShiftFloating(
setState((prev) => ({...prev, isReady: false, offsetX: 0, offsetY: 0}));
return;
}
const floating = floatingRef.current;
const floating = floatingElement;
updatePosition();
if (shouldAutoUpdate) {
const cleanupCallbacks = [
@@ -502,7 +507,7 @@ export function useAntiShiftFloating(
}
};
} else if (shouldObserveFloatingResize) {
cleanupRef.current = observeFloatingResize(floatingRef.current, updatePosition);
cleanupRef.current = observeFloatingResize(floating, updatePosition);
}
return () => {
positionRevisionRef.current += 1;
@@ -519,9 +524,18 @@ export function useAntiShiftFloating(
}
setState((prev) => ({...prev, isReady: false, offsetX: 0, offsetY: 0}));
};
}, [enabled, target, shouldAutoUpdate, shouldObserveFloatingResize, updatePosition, updatePositionNow]);
}, [
enabled,
target,
floatingElement,
shouldAutoUpdate,
shouldObserveFloatingResize,
updatePosition,
updatePositionNow,
]);
return {
ref: floatingRef,
setFloating,
state,
style: {
position: 'fixed' as const,
@@ -271,7 +271,7 @@ function SelectionToolbarSurface({
}),
[editor],
);
const {ref, state, style, updatePosition} = useAntiShiftFloating(virtualReference, true, {
const {setFloating, state, style, updatePosition} = useAntiShiftFloating(virtualReference, true, {
placement: 'top',
offsetMainAxis: 8,
shouldAutoUpdate: true,
@@ -381,7 +381,7 @@ function SelectionToolbarSurface({
>
<flx-lexical-selection-formatting-toolbar
ref={(element) => {
(ref as React.MutableRefObject<HTMLElement | null>).current = element;
setFloating(element);
toolbarRef.current = element;
}}
className={flxElementClassName(styles.toolbar)}
@@ -186,6 +186,7 @@ const PopoutItem: React.FC<PopoutItemProps> = observer(
);
const {
ref: popoutRef,
setFloating,
state,
style,
beginManualPositioning,
@@ -206,7 +207,7 @@ const PopoutItem: React.FC<PopoutItemProps> = observer(
useLayoutEffect(() => {
focusRefs.setReference(target);
}, [focusRefs, target]);
const mergedPopoutRef = useMergeRefs([popoutRef, focusRefs.setFloating]);
const mergedPopoutRef = useMergeRefs([setFloating, focusRefs.setFloating]);
const prefersReducedMotion = Accessibility.useReducedMotion;
const [isVisible, setIsVisible] = useState(true);
const [targetInDOM, setTargetInDOM] = useState(() => ownerDocument.contains(target));