fix(ui): prevent browser zoom in floating menus (#7489)

This commit is contained in:
Univer
2026-08-12 10:22:18 +08:00
committed by GitHub
parent 3350da7e43
commit 5a8680fbae
5 changed files with 141 additions and 2 deletions
@@ -16,8 +16,8 @@
import type { IMenuManagerService as IMenuManagerServiceType, IMenuSchema } from '@univerjs/ui';
import { borderClassName, clsx } from '@univerjs/design';
import { IMenuManagerService, MenuManagerPosition, ToolbarItem, useDependency } from '@univerjs/ui';
import { useEffect, useState } from 'react';
import { IMenuManagerService, MenuManagerPosition, preventBrowserZoomInContainers, ToolbarItem, useDependency } from '@univerjs/ui';
import { useEffect, useRef, useState } from 'react';
import {
SetInlineFormatBoldCommand,
SetInlineFormatFontSizeCommand,
@@ -92,6 +92,7 @@ export function FloatToolbar(props: IFloatToolbarProps) {
const { avaliableMenus = DEFAULT_AVALIABLE_MENUS } = props;
const menuManagerService = useDependency(IMenuManagerService);
const toolbarRef = useRef<HTMLDivElement>(null);
const [menus, setMenus] = useState<IFloatToolbarMenuSchema[]>([]);
const [extraMenus, setExtraMenus] = useState<IMenuSchema[]>([]);
@@ -112,8 +113,18 @@ export function FloatToolbar(props: IFloatToolbarProps) {
};
}, [avaliableMenus, menuManagerService]);
useEffect(() => {
const toolbar = toolbarRef.current;
if (!toolbar) {
return;
}
return preventBrowserZoomInContainers([toolbar]);
}, []);
return (
<div
ref={toolbarRef}
className={clsx(`
univer-box-border univer-flex univer-rounded univer-bg-white univer-py-1.5 univer-shadow-sm
dark:!univer-border-gray-700 dark:!univer-bg-gray-900
+1
View File
@@ -181,6 +181,7 @@ export type { IFontFamilyItemProps } from './views/font-family/FontFamilyItem';
export { FontSize } from './views/font-size/FontSize';
export { FONT_SIZE_COMPONENT, FONT_SIZE_LIST, HEADING_LIST } from './views/font-size/interface';
export * from './views/hooks/index';
export { preventBrowserZoomInContainers } from './views/hooks/prevent-browser-zoom';
export * from './views/index';
export { type INotificationOptions } from './views/notification/Notification';
export { ProgressBar } from './views/progress-bar/ProgressBar';
@@ -36,6 +36,7 @@ import { IMenuManagerService } from '../../../services/menu/menu-manager.service
import { useDependency, useObservable } from '../../../utils/di';
import { CustomLabel } from '../../custom-label/CustomLabel';
import { useScrollYOverContainer } from '../../hooks/layout';
import { preventBrowserZoomInContainers } from '../../hooks/prevent-browser-zoom';
import { resolveMenuItemActiveState, UIQuickTileMenuGroup, UITinyMenuGroup } from '../../menu/desktop/TinyMenuGroup';
type ContextMenuSizeVariant = 'default' | 'paragraph-t';
@@ -702,6 +703,14 @@ export function ContextMenuPanel(props: IContextMenuPanelProps) {
useScrollYOverContainer(menuElement, layoutService.rootContainerElement);
useEffect(() => {
if (!menuElement) {
return;
}
return preventBrowserZoomInContainers([menuElement]);
}, [menuElement]);
const getFocusableMenuButtons = useCallback(() => {
if (!menuElement) {
return [];
@@ -1195,6 +1204,15 @@ function ContextMenuMenuItem(props: IContextMenuMenuItemProps) {
}
}, [closeSubmenu, disabled]);
useEffect(() => {
const submenuElement = submenuElementRef.current;
if (!submenuVisible || !submenuElement) {
return;
}
return preventBrowserZoomInContainers([submenuElement]);
}, [submenuVisible]);
useEffect(() => {
if (!submenuVisible) {
setSubmenuPositionReady(false);
@@ -0,0 +1,70 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { describe, expect, it, vi } from 'vitest';
import { preventBrowserZoomInContainers } from '../prevent-browser-zoom';
describe('preventBrowserZoomInContainers', () => {
it('prevents modified wheel defaults before a child stops propagation', () => {
const container = document.createElement('div');
const child = document.createElement('div');
const childListener = vi.fn((event: WheelEvent) => event.stopPropagation());
container.appendChild(child);
child.addEventListener('wheel', childListener);
const dispose = preventBrowserZoomInContainers([container]);
const event = new WheelEvent('wheel', { bubbles: true, cancelable: true });
Object.defineProperty(event, 'ctrlKey', { value: true });
child.dispatchEvent(event);
expect(event.defaultPrevented).toBe(true);
expect(childListener).toHaveBeenCalledTimes(1);
dispose();
});
it('leaves ordinary wheel scrolling unchanged', () => {
const container = document.createElement('div');
const dispose = preventBrowserZoomInContainers([container]);
const event = new WheelEvent('wheel', { bubbles: true, cancelable: true });
container.dispatchEvent(event);
expect(event.defaultPrevented).toBe(false);
dispose();
});
it('prevents Safari gesture zoom and removes every listener on cleanup', () => {
const container = document.createElement('div');
const dispose = preventBrowserZoomInContainers([container]);
const gestureStart = new Event('gesturestart', { bubbles: true, cancelable: true });
const gestureChange = new Event('gesturechange', { bubbles: true, cancelable: true });
container.dispatchEvent(gestureStart);
container.dispatchEvent(gestureChange);
expect(gestureStart.defaultPrevented).toBe(true);
expect(gestureChange.defaultPrevented).toBe(true);
dispose();
const wheelAfterDispose = new WheelEvent('wheel', { bubbles: true, cancelable: true, metaKey: true });
const gestureAfterDispose = new Event('gesturestart', { bubbles: true, cancelable: true });
container.dispatchEvent(wheelAfterDispose);
container.dispatchEvent(gestureAfterDispose);
expect(wheelAfterDispose.defaultPrevented).toBe(false);
expect(gestureAfterDispose.defaultPrevented).toBe(false);
});
});
@@ -0,0 +1,39 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export function preventBrowserZoomInContainers(containers: readonly HTMLElement[]): () => void {
const uniqueContainers = new Set(containers);
const handleWheel = (event: WheelEvent): void => {
if (event.ctrlKey || event.metaKey) {
event.preventDefault();
}
};
const handleGesture = (event: Event): void => event.preventDefault();
uniqueContainers.forEach((container) => {
container.addEventListener('wheel', handleWheel, { capture: true, passive: false });
container.addEventListener('gesturestart', handleGesture, { capture: true, passive: false });
container.addEventListener('gesturechange', handleGesture, { capture: true, passive: false });
});
return () => {
uniqueContainers.forEach((container) => {
container.removeEventListener('wheel', handleWheel, true);
container.removeEventListener('gesturestart', handleGesture, true);
container.removeEventListener('gesturechange', handleGesture, true);
});
};
}