diff --git a/.editorconfig b/.editorconfig index 348eaed0b4..d30a8dbbe9 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,6 +1,5 @@ [*] charset=utf-8 -end_of_line=lf indent_size=4 indent_style=space insert_final_newline=true diff --git a/packages/design/src/components/config-provider/ConfigProvider.tsx b/packages/design/src/components/config-provider/ConfigProvider.tsx index faf4059077..8fd2db9b9e 100644 --- a/packages/design/src/components/config-provider/ConfigProvider.tsx +++ b/packages/design/src/components/config-provider/ConfigProvider.tsx @@ -24,6 +24,14 @@ export interface IConfigProviderProps { locale?: any; direction?: 'ltr' | 'rtl'; mountContainer: HTMLElement | null; + mobileKeyboardViewport?: IMobileKeyboardViewport | null; +} + +export interface IMobileKeyboardViewport { + top: number; + bottom: number; + height: number; + stableHeight: number; } export const ConfigContext = createContext>({ @@ -31,15 +39,16 @@ export const ConfigContext = createContext { return { locale, direction, mountContainer, + mobileKeyboardViewport, }; - }, [locale, direction, mountContainer]); + }, [locale, direction, mobileKeyboardViewport, mountContainer]); return ( diff --git a/packages/design/src/components/dialog/__tests__/MobileDialog.spec.tsx b/packages/design/src/components/dialog/__tests__/MobileDialog.spec.tsx index 114e393346..e03ad78d11 100644 --- a/packages/design/src/components/dialog/__tests__/MobileDialog.spec.tsx +++ b/packages/design/src/components/dialog/__tests__/MobileDialog.spec.tsx @@ -16,10 +16,14 @@ import { cleanup, fireEvent, render, screen } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; +import { ConfigProvider } from '../../config-provider/ConfigProvider'; import { MobileDialog } from '../MobileDialog'; import '@testing-library/jest-dom/vitest'; -afterEach(cleanup); +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); describe('MobileDialog', () => { it('keeps keyboard focus out of input fields until the user edits', () => { @@ -36,4 +40,28 @@ describe('MobileDialog', () => { fireEvent.click(screen.getByRole('button', { name: 'Close' })); expect(onClose).toHaveBeenCalledTimes(1); }); + + it('keeps its original geometry while the keyboard viewport is visible', () => { + vi.stubGlobal('CSS', { supports: () => false }); + vi.stubGlobal('innerHeight', 768); + vi.stubGlobal('visualViewport', { + height: 448, + offsetTop: 0, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }); + render( + + Content + + ); + + const dialog = screen.getByRole('dialog'); + expect(dialog.style.bottom).toBe('0px'); + expect(dialog.style.maxHeight).toBe('80vh'); + expect(dialog.querySelector('.univer-overflow-y-auto')?.style.paddingBottom).toBe(''); + }); }); diff --git a/packages/design/src/hooks/__tests__/use-mobile-keyboard-viewport.spec.ts b/packages/design/src/hooks/__tests__/use-mobile-keyboard-viewport.spec.ts new file mode 100644 index 0000000000..154cd798f3 --- /dev/null +++ b/packages/design/src/hooks/__tests__/use-mobile-keyboard-viewport.spec.ts @@ -0,0 +1,107 @@ +/** + * 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 { afterEach, describe, expect, it, vi } from 'vitest'; +import { + resolveMobileKeyboardViewportLayout, + revealFocusedElementInMobileViewport, +} from '../use-mobile-keyboard-viewport'; + +afterEach(() => { + document.body.replaceChildren(); +}); + +describe('mobile keyboard viewport', () => { + it('maps the visible viewport into both stable and already-resized containing blocks', () => { + const viewport = { top: 0, bottom: 520, height: 520, stableHeight: 852 }; + + expect(resolveMobileKeyboardViewportLayout(0, 852, viewport, 0.4)).toEqual({ + availableHeight: 520, + bottom: 332, + height: 340.8, + }); + expect(resolveMobileKeyboardViewportLayout(0, 520, viewport, 0.8)).toEqual({ + availableHeight: 520, + bottom: 0, + height: 520, + }); + }); + + it('scrolls the nearest panel content so the focused input stays visible', () => { + const container = document.createElement('section'); + const scroller = document.createElement('div'); + const input = document.createElement('input'); + scroller.style.overflowY = 'auto'; + scroller.appendChild(input); + container.appendChild(scroller); + document.body.appendChild(container); + input.focus(); + + Object.defineProperties(scroller, { + clientHeight: { configurable: true, value: 200 }, + scrollHeight: { configurable: true, value: 800 }, + }); + scroller.getBoundingClientRect = () => ({ + bottom: 300, + height: 200, + left: 0, + right: 320, + top: 100, + width: 320, + x: 0, + y: 100, + toJSON: () => undefined, + }); + input.getBoundingClientRect = () => ({ + bottom: 380, + height: 40, + left: 0, + right: 320, + top: 340, + width: 320, + x: 0, + y: 340, + toJSON: () => undefined, + }); + + expect(revealFocusedElementInMobileViewport(container, { top: 0, bottom: 300 })).toBe(true); + expect(scroller.scrollTop).toBe(92); + }); + + it('asks the browser to reveal a covered input when there is no scrollable panel', () => { + const container = document.createElement('section'); + const input = document.createElement('input'); + const scrollIntoView = vi.fn(); + input.scrollIntoView = scrollIntoView; + container.appendChild(input); + document.body.appendChild(container); + input.focus(); + input.getBoundingClientRect = () => ({ + bottom: 380, + height: 40, + left: 0, + right: 320, + top: 340, + width: 320, + x: 0, + y: 340, + toJSON: () => undefined, + }); + + expect(revealFocusedElementInMobileViewport(container, { top: 0, bottom: 300 })).toBe(true); + expect(scrollIntoView).toHaveBeenCalledWith({ block: 'nearest', inline: 'nearest' }); + }); +}); diff --git a/packages/design/src/hooks/use-mobile-keyboard-viewport.ts b/packages/design/src/hooks/use-mobile-keyboard-viewport.ts new file mode 100644 index 0000000000..a5a85a4b21 --- /dev/null +++ b/packages/design/src/hooks/use-mobile-keyboard-viewport.ts @@ -0,0 +1,145 @@ +/** + * 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 type { RefObject } from 'react'; +import type { IMobileKeyboardViewport } from '../components/config-provider/ConfigProvider'; +import { useCallback, useContext, useLayoutEffect, useReducer, useRef } from 'react'; +import { ConfigContext } from '../components/config-provider/ConfigProvider'; + +export interface IMobileKeyboardViewportLayout { + availableHeight: number; + bottom: number; + height?: number; +} + +export function resolveMobileKeyboardViewportLayout( + containingBlockTop: number, + containingBlockBottom: number, + viewport: IMobileKeyboardViewport, + heightRatio?: number +): IMobileKeyboardViewportLayout { + const visibleTop = Math.max(containingBlockTop, viewport.top); + const visibleBottom = Math.min(containingBlockBottom, viewport.bottom); + const availableHeight = Math.max(0, visibleBottom - visibleTop); + + return { + availableHeight, + bottom: Math.max(0, containingBlockBottom - visibleBottom), + height: heightRatio == null + ? undefined + : Math.min(viewport.stableHeight * heightRatio, availableHeight), + }; +} + +export function revealFocusedElementInMobileViewport( + container: HTMLElement, + viewport: Pick +): boolean { + const activeElement = document.activeElement; + if (!(activeElement instanceof HTMLElement) || !container.contains(activeElement)) { + return false; + } + + const margin = 12; + const targetRect = activeElement.getBoundingClientRect(); + let scrollContainer = activeElement.parentElement; + while (scrollContainer && scrollContainer !== container) { + const overflowY = getComputedStyle(scrollContainer).overflowY; + if ((overflowY === 'auto' || overflowY === 'scroll') && + scrollContainer.scrollHeight > scrollContainer.clientHeight) { + const scrollRect = scrollContainer.getBoundingClientRect(); + const safeTop = Math.max(scrollRect.top, viewport.top) + margin; + const safeBottom = Math.min(scrollRect.bottom, viewport.bottom) - margin; + if (targetRect.bottom > safeBottom) { + const previousScrollTop = scrollContainer.scrollTop; + scrollContainer.scrollTop += targetRect.bottom - safeBottom; + if (targetRect.bottom - (scrollContainer.scrollTop - previousScrollTop) <= safeBottom) { + return true; + } + break; + } + if (targetRect.top < safeTop) { + const previousScrollTop = scrollContainer.scrollTop; + scrollContainer.scrollTop -= safeTop - targetRect.top; + if (targetRect.top + (previousScrollTop - scrollContainer.scrollTop) >= safeTop) { + return true; + } + break; + } + return false; + } + scrollContainer = scrollContainer.parentElement; + } + + const safeTop = viewport.top + margin; + const safeBottom = viewport.bottom - margin; + if (targetRect.bottom > safeBottom || targetRect.top < safeTop) { + activeElement.scrollIntoView({ block: 'nearest', inline: 'nearest' }); + return true; + } + + return false; +} + +export function useMobileKeyboardViewportLayout( + elementRef: RefObject, + heightRatio?: number +): IMobileKeyboardViewportLayout | null { + const { mobileKeyboardViewport } = useContext(ConfigContext); + const [layout, updateLayout] = useReducer( + (_current: IMobileKeyboardViewportLayout | null, next: IMobileKeyboardViewportLayout | null) => next, + null + ); + const layoutRef = useRef(null); + + const measure = useCallback(() => { + const element = elementRef.current; + if (!element || !mobileKeyboardViewport) { + if (layoutRef.current !== null) { + layoutRef.current = null; + updateLayout(null); + } + return; + } + + const containingBlock = element.offsetParent; + const fixedToViewport = getComputedStyle(element).position === 'fixed' || !containingBlock; + const containingBlockRect = containingBlock?.getBoundingClientRect(); + const nextLayout = resolveMobileKeyboardViewportLayout( + fixedToViewport ? 0 : containingBlockRect?.top ?? 0, + fixedToViewport ? window.innerHeight : containingBlockRect?.bottom ?? window.innerHeight, + mobileKeyboardViewport, + heightRatio + ); + const previous = layoutRef.current; + if (previous?.availableHeight === nextLayout.availableHeight && + previous.bottom === nextLayout.bottom && + previous.height === nextLayout.height) { + return; + } + + layoutRef.current = nextLayout; + updateLayout(nextLayout); + }, [elementRef, heightRatio, mobileKeyboardViewport]); + + useLayoutEffect(() => { + measure(); + const frame = requestAnimationFrame(measure); + return () => cancelAnimationFrame(frame); + }); + + return layout; +} diff --git a/packages/design/src/index.ts b/packages/design/src/index.ts index ac5ed150da..1e97728f50 100644 --- a/packages/design/src/index.ts +++ b/packages/design/src/index.ts @@ -58,7 +58,7 @@ export { CommandShortcut, } from './components/command/Command'; export { ConfigContext, ConfigProvider } from './components/config-provider/ConfigProvider'; -export type { IConfigProviderProps } from './components/config-provider/ConfigProvider'; +export type { IConfigProviderProps, IMobileKeyboardViewport } from './components/config-provider/ConfigProvider'; export { Confirm } from './components/confirm/Confirm'; export type { IConfirmProps } from './components/confirm/Confirm'; export { MobileConfirm } from './components/confirm/MobileConfirm'; @@ -155,5 +155,11 @@ export { export { clsx } from './helper/clsx'; export { isBrowser } from './helper/is-browser'; export { render, unmount } from './helper/react-dom'; +export { + resolveMobileKeyboardViewportLayout, + revealFocusedElementInMobileViewport, + useMobileKeyboardViewportLayout, +} from './hooks/use-mobile-keyboard-viewport'; +export type { IMobileKeyboardViewportLayout } from './hooks/use-mobile-keyboard-viewport'; export { cva } from 'class-variance-authority'; diff --git a/packages/docs-ui/src/views/mobile-rich-text-editor/MobileRichTextEditor.tsx b/packages/docs-ui/src/views/mobile-rich-text-editor/MobileRichTextEditor.tsx index 768d363523..61042dd058 100644 --- a/packages/docs-ui/src/views/mobile-rich-text-editor/MobileRichTextEditor.tsx +++ b/packages/docs-ui/src/views/mobile-rich-text-editor/MobileRichTextEditor.tsx @@ -16,7 +16,7 @@ import type { Editor } from '../../services/editor/editor'; import type { IRichTextEditorProps } from '../RichTextEditor'; -import { Button, clsx } from '@univerjs/design'; +import { Button, clsx, useMobileKeyboardViewportLayout } from '@univerjs/design'; import { DocSkeletonManagerService } from '@univerjs/docs'; import { CheckMarkIcon, CloseIcon, DownIcon } from '@univerjs/icons'; import { MobileKeyboardInsetContext, useEvent, useMobileCanvasPanel } from '@univerjs/ui'; @@ -42,6 +42,7 @@ export function MobileRichTextEditor(props: IMobileRichTextEditorProps) { const keyboardWasVisibleRef = useRef(false); const [editor, setEditor] = useState(null); const [contentHeight, setContentHeight] = useState(0); + const keyboardLayout = useMobileKeyboardViewportLayout(containerRef); const confirmOnDismiss = useEvent(onConfirm); useMobileCanvasPanel(containerRef, expanded ? 'modal' : 'canvas'); @@ -92,7 +93,7 @@ export function MobileRichTextEditor(props: IMobileRichTextEditorProps) { : 'univer-min-h-12 univer-items-center')} style={{ backgroundColor: props.canvasStyle?.backgroundColor, - bottom: expanded ? undefined : keyboardInset, + bottom: expanded ? undefined : keyboardLayout?.bottom ?? 0, paddingBottom: expanded ? 'env(safe-area-inset-bottom, 0px)' : undefined, paddingTop: expanded ? 'env(safe-area-inset-top, 0px)' : undefined, }} diff --git a/packages/drawing-ui/src/views/__tests__/DrawingPanelActions.spec.tsx b/packages/drawing-ui/src/views/__tests__/DrawingPanelActions.spec.tsx index e5da9d7881..94dd2ee973 100644 --- a/packages/drawing-ui/src/views/__tests__/DrawingPanelActions.spec.tsx +++ b/packages/drawing-ui/src/views/__tests__/DrawingPanelActions.spec.tsx @@ -435,6 +435,59 @@ describe('drawing panel actions', () => { expect(document.querySelector('[role="dialog"]')).toBeNull(); }); + it('renders button-only drawing dialogs with mobile action rows', async () => { + const executedCommands: ICommandInfo[] = []; + commandService.onCommandExecuted((command) => executedCommands.push(command)); + + const rendered = renderWithRediContext( + univer.__getInjector(), + + + + ); + root = rendered.root; + container = rendered.container; + + const edit = Array.from(container.querySelectorAll('button')).find((button) => button.textContent === 'drawing.edit'); + if (!edit) { + throw new Error('The mobile drawing action row was not found'); + } + expect(edit.classList.contains('univer-rounded-xl')).toBe(true); + expect(edit.classList.contains('univer-bg-gray-100')).toBe(true); + + const deleteAction = Array.from(container.querySelectorAll('button')) + .find((button) => button.textContent === 'drawing.delete'); + if (!deleteAction) { + throw new Error('The second mobile drawing action row was not found'); + } + clickElement(deleteAction); + await flushPendingCommands(); + + expect(executedCommands).toContainEqual({ + id: chartEditCommandId, + type: CommandType.OPERATION, + params: { source: 'second-action' }, + }); + }); + it('executes select options from the shared mobile drawing drawer', async () => { const executedCommands: ICommandInfo[] = []; commandService.onCommandExecuted((command) => executedCommands.push(command)); diff --git a/packages/drawing-ui/src/views/image-popup-menu/MobileImagePopupMenu.tsx b/packages/drawing-ui/src/views/image-popup-menu/MobileImagePopupMenu.tsx index 98287c121e..8c3f4a9d24 100644 --- a/packages/drawing-ui/src/views/image-popup-menu/MobileImagePopupMenu.tsx +++ b/packages/drawing-ui/src/views/image-popup-menu/MobileImagePopupMenu.tsx @@ -15,9 +15,11 @@ */ import type { IMenuSchema } from '@univerjs/ui'; +import type { LocaleKey } from '../../locale/types'; import type { IImagePopupMenuExtraProps } from './ImagePopupMenu'; -import { ICommandService } from '@univerjs/core'; -import { IDialogService, MenuItemType, MobileMenu, useDependency } from '@univerjs/ui'; +import { ICommandService, LocaleService } from '@univerjs/core'; +import { MobileActionRow } from '@univerjs/design'; +import { IconManager, IDialogService, MenuItemType, MobileMenu, useDependency } from '@univerjs/ui'; import { of } from 'rxjs'; interface IMobileImagePopupMenuProps { @@ -29,11 +31,58 @@ interface IMobileImagePopupMenuProps { export function MobileImagePopupMenu({ popup }: IMobileImagePopupMenuProps) { const commandService = useDependency(ICommandService); const dialogService = useDependency(IDialogService); - const menuItems = popup.extraProps?.menuItems; - if (!menuItems) { + const iconManager = useDependency(IconManager); + const localeService = useDependency(LocaleService); + const menuItems = popup.extraProps?.menuItems ?? []; + if (menuItems.length === 0) { return null; } + async function executeMenuItem(item: IImagePopupMenuExtraProps['menuItems'][number] | undefined, value?: unknown) { + if (!item || item.disable) { + return; + } + if (item.type === 'select' && (typeof value !== 'string' || !item.options?.some((option) => option.value === value))) { + return; + } + const params = item.type === 'select' && typeof value === 'string' + ? item.commandParamsFactory?.(value) ?? { ...item.commandParams, value } + : item.commandParams; + await commandService.executeCommand(item.commandId, params); + if (item.hideOnClick !== false) { + popup.extraProps?.onClose?.(); + if (popup.extraProps?.dialogId) { + dialogService.close(popup.extraProps.dialogId); + } + } + } + + const useSchemaMenu = Boolean(popup.extraProps?.variant) || menuItems.some((item) => item.type === 'select'); + if (!useSchemaMenu) { + return ( +
+ {menuItems.map((item) => { + const label = localeService.t(item.label as LocaleKey); + const Icon = item.icon && iconManager.has(item.icon) ? iconManager.get(item.icon) : null; + + return ( + : undefined} + variant="subtle" + disabled={item.disable} + onClick={async () => { + await executeMenuItem(item); + }} + /> + ); + })} +
+ ); + } + const schemas: IMenuSchema[] = menuItems.map((item) => ({ key: item.commandId, order: item.index, @@ -59,23 +108,7 @@ export function MobileImagePopupMenu({ popup }: IMobileImagePopupMenuProps) { presentation={popup.extraProps?.variant ? 'context-bar' : 'drawer'} schemas={schemas} onOptionSelect={async ({ id, value }) => { - const item = menuItems.find((item) => item.commandId === id); - if (!item || item.disable) { - return; - } - if (item.type === 'select' && (typeof value !== 'string' || !item.options?.some((option) => option.value === value))) { - return; - } - const params = item.type === 'select' && typeof value === 'string' - ? item.commandParamsFactory?.(value) ?? { ...item.commandParams, value } - : item.commandParams; - await commandService.executeCommand(item.commandId, params); - if (item.hideOnClick !== false) { - popup.extraProps?.onClose?.(); - if (popup.extraProps?.dialogId) { - dialogService.close(popup.extraProps.dialogId); - } - } + await executeMenuItem(menuItems.find((item) => item.commandId === id), value); }} /> ); diff --git a/packages/find-replace/src/views/mobile/MobileFindReplaceBar.tsx b/packages/find-replace/src/views/mobile/MobileFindReplaceBar.tsx index 7ab6c5b399..b88e52811e 100644 --- a/packages/find-replace/src/views/mobile/MobileFindReplaceBar.tsx +++ b/packages/find-replace/src/views/mobile/MobileFindReplaceBar.tsx @@ -17,10 +17,10 @@ import type { ReactNode } from 'react'; import type { LocaleKey } from '../../locale/types'; import { ICommandService, LocaleService } from '@univerjs/core'; -import { clsx, Input, resetButtonClassName } from '@univerjs/design'; +import { clsx, Input, resetButtonClassName, useMobileKeyboardViewportLayout } from '@univerjs/design'; import { ArrowDownIcon, ArrowUpIcon, CloseIcon, ConfigureTabIcon, SearchIcon } from '@univerjs/icons'; -import { IDialogService, MobileKeyboardInsetContext, useDependency, useObservable } from '@univerjs/ui'; -import { useContext, useEffect, useRef, useState } from 'react'; +import { IDialogService, useDependency, useObservable } from '@univerjs/ui'; +import { useEffect, useRef, useState } from 'react'; import { ReplaceAllMatchesCommand, ReplaceCurrentMatchCommand } from '../../commands/commands/replace.command'; import { CloseFindDialogOperation, @@ -47,7 +47,6 @@ export function MobileFindReplaceBar() { } function MobileFindReplaceBarContent() { - const keyboardInset = useContext(MobileKeyboardInsetContext); const commandService = useDependency(ICommandService); const dialogService = useDependency(IDialogService); const findReplaceService = useDependency(IFindReplaceService); @@ -57,9 +56,11 @@ function MobileFindReplaceBarContent() { const replaceables = useObservable(findReplaceService.replaceables$, [], true); const [findValue, setFindValue] = useState(() => state.replaceRevealed ? state.inputtingFindString : state.findString); + const barRef = useRef(null); const inputRef = useRef(null); const searchTimerRef = useRef | null>(null); const suppressNextFocusRef = useRef(false); + const keyboardLayout = useMobileKeyboardViewportLayout(barRef); useEffect(() => { const frame = requestAnimationFrame(() => inputRef.current?.focus()); @@ -159,6 +160,7 @@ function MobileFindReplaceBarContent() { return (
diff --git a/packages/rpc/src/controllers/data-sync/__tests__/data-sync.controller.spec.ts b/packages/rpc/src/controllers/data-sync/__tests__/data-sync.controller.spec.ts index 89e2c095f6..81f9229051 100644 --- a/packages/rpc/src/controllers/data-sync/__tests__/data-sync.controller.spec.ts +++ b/packages/rpc/src/controllers/data-sync/__tests__/data-sync.controller.spec.ts @@ -14,13 +14,14 @@ * limitations under the License. */ -import type { ICommandService, Injector, IUniverInstanceService } from '@univerjs/core'; +import type { ICommandService, ILogService, Injector, IUniverInstanceService } from '@univerjs/core'; import type { IRPCChannelService } from '../../../services/rpc/channel.service'; import type { IChannel } from '../../../services/rpc/rpc.service'; import { CommandType, UniverInstanceType } from '@univerjs/core'; import { Subject } from 'rxjs'; import { describe, expect, it, vi } from 'vitest'; import { IRemoteInstanceService, IRemoteSyncService, RemoteInstanceServiceName, RemoteSyncServiceName } from '../../../services/remote-instance/remote-instance.service'; +import { ChannelClientDisposedError } from '../../../services/rpc/rpc.service'; import { DataSyncPrimaryController } from '../data-sync-primary.controller'; import { DataSyncReplicaController } from '../data-sync-replica.controller'; @@ -110,13 +111,17 @@ describe('data-sync controllers', () => { const remoteSyncService = { syncMutation: vi.fn(async () => true), }; + const logService = { + error: vi.fn(), + } as unknown as ILogService; const controller = new DataSyncPrimaryController( injector, commandService, univerInstanceService, rpcChannelService, - remoteSyncService as never + remoteSyncService as never, + logService ); expect(rpcChannelService.registerChannel).toHaveBeenCalledWith(RemoteSyncServiceName, expect.any(Object)); @@ -205,6 +210,30 @@ describe('data-sync controllers', () => { expect(remoteInstanceImpl.syncMutation).toHaveBeenCalledTimes(3); unit3Disposable.dispose(); + remoteInstanceImpl.createInstance.mockRejectedValueOnce(new ChannelClientDisposedError()); + added$.next({ + unit: { + getUnitId: () => 'unit-4', + getSnapshot: () => ({ snapshot: true }), + }, + }); + await flushPromises(); + expect(logService.error).not.toHaveBeenCalled(); + + const remoteError = new Error('create failed'); + remoteInstanceImpl.createInstance.mockRejectedValueOnce(remoteError); + added$.next({ + unit: { + getUnitId: () => 'unit-5', + getSnapshot: () => ({ snapshot: true }), + }, + }); + await flushPromises(); + expect(logService.error).toHaveBeenCalledWith( + '[DataSyncPrimaryController]: Failed to create the remote workbook.', + remoteError + ); + controller.dispose(); }); @@ -262,13 +291,17 @@ describe('data-sync controllers', () => { const remoteSyncService = { syncMutation: vi.fn(async () => true), }; + const logService = { + error: vi.fn(), + } as unknown as ILogService; const controller = new DataSyncPrimaryController( injector, commandService, univerInstanceService, rpcChannelService, - remoteSyncService as never + remoteSyncService as never, + logService ); controller.registerSyncingMutations({ id: 'm-source' } as never); diff --git a/packages/rpc/src/controllers/data-sync/data-sync-primary.controller.ts b/packages/rpc/src/controllers/data-sync/data-sync-primary.controller.ts index bd6335a060..3a132dd5bc 100644 --- a/packages/rpc/src/controllers/data-sync/data-sync-primary.controller.ts +++ b/packages/rpc/src/controllers/data-sync/data-sync-primary.controller.ts @@ -19,6 +19,7 @@ import type { IRemoteSyncMutationOptions } from '../../services/remote-instance/ import { CommandType, ICommandService, + ILogService, Inject, Injector, IUniverInstanceService, @@ -34,7 +35,7 @@ import { RemoteSyncServiceName, } from '../../services/remote-instance/remote-instance.service'; import { IRPCChannelService } from '../../services/rpc/channel.service'; -import { fromModule, toModule } from '../../services/rpc/rpc.service'; +import { ChannelClientDisposedError, fromModule, toModule } from '../../services/rpc/rpc.service'; /** * This controller is responsible for syncing data from the primary thread to @@ -58,7 +59,8 @@ export class DataSyncPrimaryController extends RxDisposable { @ICommandService private readonly _commandService: ICommandService, @IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService, @IRPCChannelService private readonly _rpcChannelService: IRPCChannelService, - @IRemoteSyncService private readonly _remoteSyncService: IRemoteSyncService + @IRemoteSyncService private readonly _remoteSyncService: IRemoteSyncService, + @ILogService private readonly _logService: ILogService ) { super(); @@ -80,11 +82,11 @@ export class DataSyncPrimaryController extends RxDisposable { const unit = this._univerInstanceService.getUnit(unitId, UniverInstanceType.UNIVER_SHEET) ?? this._univerInstanceService.getUnit(unitId, UniverInstanceType.UNIVER_BASE); if (!alreadySyncing && unit) { - this._remoteInstanceService.createInstance({ + this._handleRemoteRequest(this._remoteInstanceService.createInstance({ unitID: unit.getUnitId(), type: unit.type, snapshot: unit.getSnapshot(), - }); + }), 'create the remote unit'); } return toDisposable(() => { @@ -92,9 +94,9 @@ export class DataSyncPrimaryController extends RxDisposable { this._syncingUnits.delete(unitId); } if (!alreadySyncing && unit) { - this._remoteInstanceService.disposeInstance({ + this._handleRemoteRequest(this._remoteInstanceService.disposeInstance({ unitID: unit.getUnitId(), - }); + }), 'dispose the remote unit'); } }); } @@ -135,6 +137,7 @@ export class DataSyncPrimaryController extends RxDisposable { ]); this._remoteInstanceService = this._injector.get(IRemoteInstanceService); this._remoteReady = this._remoteInstanceService.whenReady(); + this._handleRemoteRequest(this._remoteReady, 'initialize the remote instance service'); } private _init(): void { @@ -143,19 +146,19 @@ export class DataSyncPrimaryController extends RxDisposable { this._syncingUnits.add(sheet.getUnitId()); // If a sheet is created, it should sync the data to the worker thread. - this._remoteInstanceService.createInstance({ + this._handleRemoteRequest(this._remoteInstanceService.createInstance({ unitID: sheet.getUnitId(), type: UniverInstanceType.UNIVER_SHEET, snapshot: sheet.getSnapshot(), - }); + }), 'create the remote workbook'); }); this._univerInstanceService.getTypeOfUnitDisposed$(UniverInstanceType.UNIVER_SHEET).pipe(takeUntil(this.dispose$)).subscribe((workbook) => { this._syncingUnits.delete(workbook.getUnitId()); // If a sheet is disposed, it should sync the data to the worker thread. - this._remoteInstanceService.disposeInstance({ + this._handleRemoteRequest(this._remoteInstanceService.disposeInstance({ unitID: workbook.getUnitId(), - }); + }), 'dispose the remote workbook'); }); // Mutations executed on the main thread should be synced to the worker thread. @@ -170,8 +173,21 @@ export class DataSyncPrimaryController extends RxDisposable { !(options as IRemoteSyncMutationOptions)?.fromSync && // do not sync mutations those are not meant to be synced this._syncingMutations.has(id)) { - void this.syncMutation(commandInfo as IMutationInfo, options); + this._handleRemoteRequest( + this.syncMutation(commandInfo as IMutationInfo, options), + `sync mutation ${id}` + ); } })); } + + private _handleRemoteRequest(request: Promise, operation: string): void { + request.catch((error: unknown) => { + if (error instanceof ChannelClientDisposedError) { + return; + } + + this._logService.error(`[DataSyncPrimaryController]: Failed to ${operation}.`, error); + }); + } } diff --git a/packages/rpc/src/services/rpc/__tests__/rpc.service.edge.spec.ts b/packages/rpc/src/services/rpc/__tests__/rpc.service.edge.spec.ts index 306d3a94e9..ff42c08b9b 100644 --- a/packages/rpc/src/services/rpc/__tests__/rpc.service.edge.spec.ts +++ b/packages/rpc/src/services/rpc/__tests__/rpc.service.edge.spec.ts @@ -18,7 +18,7 @@ import type { IChannel, IMessageProtocol } from '../rpc.service'; import { awaitTime } from '@univerjs/core'; import { Observable, Subject } from 'rxjs'; import { describe, expect, it, vi } from 'vitest'; -import { ChannelClient, ChannelServer, fromModule, toModule } from '../rpc.service'; +import { ChannelClient, ChannelClientDisposedError, ChannelServer, fromModule, toModule } from '../rpc.service'; const REQUEST_INITIALIZATION = 50; const CALL = 100; @@ -181,6 +181,7 @@ describe('rpc.service edge cases', () => { client.dispose(); await expect(pendingCall).rejects.toThrow('[ChannelClient]: client is disposed!'); + await expect(pendingCall).rejects.toBeInstanceOf(ChannelClientDisposedError); expect(subscriptionError).toHaveBeenCalledWith(expect.objectContaining({ message: '[ChannelClient]: client is disposed!', })); diff --git a/packages/rpc/src/services/rpc/rpc.service.ts b/packages/rpc/src/services/rpc/rpc.service.ts index 8f5813677d..da4091ac42 100644 --- a/packages/rpc/src/services/rpc/rpc.service.ts +++ b/packages/rpc/src/services/rpc/rpc.service.ts @@ -124,6 +124,15 @@ export interface IChannelServer { registerChannel(channelName: string, channel: T): void; } +const CHANNEL_CLIENT_DISPOSED_ERROR_MESSAGE = '[ChannelClient]: client is disposed!'; + +export class ChannelClientDisposedError extends Error { + constructor() { + super(CHANNEL_CLIENT_DISPOSED_ERROR_MESSAGE); + this.name = 'ChannelClientDisposedError'; + } +} + enum RequestType { /** * In Univer, we cannot make sure that when IPCServer constructs, the process (or thread) @@ -204,7 +213,7 @@ export class ChannelClient extends RxDisposable implements IChannelClient { return; } - const error = new Error('[ChannelClient]: client is disposed!'); + const error = new ChannelClientDisposedError(); this._initialized.error(error); for (const responseHandler of this._pendingRequests.values()) { responseHandler.dispose(error); @@ -217,14 +226,14 @@ export class ChannelClient extends RxDisposable implements IChannelClient { return { call: (method: string, args?: any) => { if (this._disposed) { - return Promise.reject(new Error('[ChannelClient]: client is disposed!')); + return Promise.reject(new ChannelClientDisposedError()); } return this._remoteCall(channelName, method, args); }, subscribe: (eventMethod: string, args?: any) => { if (this._disposed) { - throw new Error('[ChannelClient]: client is disposed!'); + throw new ChannelClientDisposedError(); } return this._remoteSubscribe(channelName, eventMethod, args); diff --git a/packages/sheets-conditional-formatting-ui/src/menu/mobile-schema.ts b/packages/sheets-conditional-formatting-ui/src/menu/mobile-schema.ts index d86649359c..840207575d 100644 --- a/packages/sheets-conditional-formatting-ui/src/menu/mobile-schema.ts +++ b/packages/sheets-conditional-formatting-ui/src/menu/mobile-schema.ts @@ -15,8 +15,15 @@ */ import type { MenuSchemaType } from '@univerjs/ui'; -import { conditionalFormattingRuleMenuSchema } from './schema'; +import { RibbonDataGroup } from '@univerjs/ui'; +import { OpenConditionalFormattingOperator } from '../commands/operations/open-conditional-formatting-panel'; +import { FactoryManageConditionalFormattingRule } from './manage-rule'; export const mobileMenuSchema: MenuSchemaType = { - ...conditionalFormattingRuleMenuSchema, + [RibbonDataGroup.RULES]: { + [OpenConditionalFormattingOperator.id]: { + order: 1, + menuItemFactory: FactoryManageConditionalFormattingRule, + }, + }, }; diff --git a/packages/sheets-drawing-ui/src/controllers/ui.controller.ts b/packages/sheets-drawing-ui/src/controllers/ui.controller.ts index ad71294cdf..e9c6513602 100644 --- a/packages/sheets-drawing-ui/src/controllers/ui.controller.ts +++ b/packages/sheets-drawing-ui/src/controllers/ui.controller.ts @@ -16,7 +16,7 @@ import { Disposable, DrawingTypeEnum, ICommandService, Inject } from '@univerjs/core'; import { IDrawingManagerService } from '@univerjs/drawing'; -import { IMenuManagerService, IShortcutService, ISidebarService } from '@univerjs/ui'; +import { IShortcutService, ISidebarService } from '@univerjs/ui'; import { DeleteDrawingsCommand } from '../commands/commands/delete-drawings.command'; import { FlipSheetDrawingCommand } from '../commands/commands/flip-drawings.command'; import { GroupSheetDrawingCommand } from '../commands/commands/group-sheet-drawing.command'; @@ -26,7 +26,6 @@ import { SaveCellImagesCommand } from '../commands/commands/save-cell-images.com import { UngroupSheetDrawingCommand } from '../commands/commands/ungroup-sheet-drawing.command'; import { EditSheetDrawingOperation } from '../commands/operations/edit-sheet-drawing.operation'; import { SidebarSheetDrawingOperation } from '../commands/operations/open-drawing-panel.operation'; -import { menuSchema } from '../menu/schema'; import { COMPONENT_SHEET_DRAWING_PANEL } from '../views/sheet-image-panel/component-name'; import { DeleteDrawingsShortcutItem, @@ -38,7 +37,6 @@ import { export class SheetDrawingUIController extends Disposable { constructor( - @IMenuManagerService private readonly _menuManagerService: IMenuManagerService, @ICommandService private readonly _commandService: ICommandService, @IShortcutService private readonly _shortcutService: IShortcutService, @IDrawingManagerService private readonly _drawingManagerService: IDrawingManagerService, @@ -52,10 +50,6 @@ export class SheetDrawingUIController extends Disposable { private _initCustomComponents(): void { } - private _initMenus(): void { - this._menuManagerService.mergeMenu(menuSchema); - } - private _initCommands() { [ InsertFloatImageCommand, @@ -101,7 +95,6 @@ export class SheetDrawingUIController extends Disposable { private _init(): void { this._initCommands(); this._initCustomComponents(); - this._initMenus(); this._initShortcuts(); this._initImagePanel(); } diff --git a/packages/sheets-drawing-ui/src/menu/mobile-schema.ts b/packages/sheets-drawing-ui/src/menu/mobile-schema.ts new file mode 100644 index 0000000000..7e98334960 --- /dev/null +++ b/packages/sheets-drawing-ui/src/menu/mobile-schema.ts @@ -0,0 +1,63 @@ +/** + * 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 type { MenuSchemaType } from '@univerjs/ui'; +import { ContextMenuGroup, ContextMenuPosition, RibbonInsertGroup } from '@univerjs/ui'; +import { InsertCellImageCommand, InsertFloatImageCommand } from '../commands/commands/insert-image.command'; +import { SaveCellImagesCommand } from '../commands/commands/save-cell-images.command'; +import { ImageMenuFactory, SHEETS_IMAGE_MENU_ID, UploadCellImageMenuFactory, UploadFloatImageMenuFactory } from './image.menu'; +import { SaveCellImagesMenuFactory } from './save-images.menu'; + +export const mobileMenuSchema: MenuSchemaType = { + [RibbonInsertGroup.MEDIA]: { + [SHEETS_IMAGE_MENU_ID]: { + order: 0, + menuItemFactory: ImageMenuFactory, + [InsertFloatImageCommand.id]: { + order: 0, + menuItemFactory: UploadFloatImageMenuFactory, + }, + [InsertCellImageCommand.id]: { + order: 1, + menuItemFactory: UploadCellImageMenuFactory, + }, + }, + }, + [ContextMenuPosition.MAIN_AREA]: { + [ContextMenuGroup.OTHERS]: { + [SaveCellImagesCommand.id]: { + order: 10, + menuItemFactory: SaveCellImagesMenuFactory, + }, + }, + }, + [ContextMenuPosition.COL_HEADER]: { + [ContextMenuGroup.OTHERS]: { + [SaveCellImagesCommand.id]: { + order: 10, + menuItemFactory: SaveCellImagesMenuFactory, + }, + }, + }, + [ContextMenuPosition.ROW_HEADER]: { + [ContextMenuGroup.OTHERS]: { + [SaveCellImagesCommand.id]: { + order: 10, + menuItemFactory: SaveCellImagesMenuFactory, + }, + }, + }, +}; diff --git a/packages/sheets-drawing-ui/src/menu/mobile-sheet-drawing-menu.controller.ts b/packages/sheets-drawing-ui/src/menu/mobile-sheet-drawing-menu.controller.ts new file mode 100644 index 0000000000..be847024a4 --- /dev/null +++ b/packages/sheets-drawing-ui/src/menu/mobile-sheet-drawing-menu.controller.ts @@ -0,0 +1,26 @@ +/** + * 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 { Disposable } from '@univerjs/core'; +import { IMenuManagerService } from '@univerjs/ui'; +import { mobileMenuSchema } from './mobile-schema'; + +export class MobileSheetDrawingMenuController extends Disposable { + constructor(@IMenuManagerService menuManagerService: IMenuManagerService) { + super(); + menuManagerService.mergeMenu(mobileMenuSchema); + } +} diff --git a/packages/sheets-drawing-ui/src/menu/sheet-drawing-menu.controller.ts b/packages/sheets-drawing-ui/src/menu/sheet-drawing-menu.controller.ts new file mode 100644 index 0000000000..553654b6b2 --- /dev/null +++ b/packages/sheets-drawing-ui/src/menu/sheet-drawing-menu.controller.ts @@ -0,0 +1,26 @@ +/** + * 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 { Disposable } from '@univerjs/core'; +import { IMenuManagerService } from '@univerjs/ui'; +import { menuSchema } from './schema'; + +export class SheetDrawingMenuController extends Disposable { + constructor(@IMenuManagerService menuManagerService: IMenuManagerService) { + super(); + menuManagerService.mergeMenu(menuSchema); + } +} diff --git a/packages/sheets-drawing-ui/src/mobile-plugin.ts b/packages/sheets-drawing-ui/src/mobile-plugin.ts index 30a5ee2e1d..d3f047e8fe 100644 --- a/packages/sheets-drawing-ui/src/mobile-plugin.ts +++ b/packages/sheets-drawing-ui/src/mobile-plugin.ts @@ -54,6 +54,7 @@ import { SheetDrawingUpdateController } from './controllers/sheet-drawing-update import { SheetDrawingUIController } from './controllers/ui.controller'; import { touchSheetsDrawingFloatingHostCapabilityWhenReady } from './embed/floating-host'; import { MobileDrawingPopupMenuController } from './menu/mobile-drawing-popup-menu.controller'; +import { MobileSheetDrawingMenuController } from './menu/mobile-sheet-drawing-menu.controller'; import { BatchSaveImagesService, IBatchSaveImagesService } from './services/batch-save-images.service'; import { SheetCanvasFloatDomManagerService } from './services/canvas-float-dom-manager.service'; import { DrawingContextMenuService, IDrawingContextMenuService } from './services/drawing-context-menu.service'; @@ -96,6 +97,7 @@ export class UniverSheetsDrawingMobileUIPlugin extends Plugin { [SheetCanvasFloatDomManagerService], [SheetDrawingHitTestService], [SheetDrawingUIController], + [MobileSheetDrawingMenuController], [MobileDrawingPopupMenuController], [SheetDrawingPrintingController], [SheetDrawingPermissionController], @@ -129,6 +131,7 @@ export class UniverSheetsDrawingMobileUIPlugin extends Plugin { [SheetDrawingPermissionController], [SheetDrawingPrintingController], [SheetDrawingUIController], + [MobileSheetDrawingMenuController], [SheetCellImageController], [SheetCellImageAutofillController], ]); diff --git a/packages/sheets-drawing-ui/src/plugin.ts b/packages/sheets-drawing-ui/src/plugin.ts index 7a0911f747..339588fdc0 100644 --- a/packages/sheets-drawing-ui/src/plugin.ts +++ b/packages/sheets-drawing-ui/src/plugin.ts @@ -52,6 +52,7 @@ import { SheetDrawingUpdateController } from './controllers/sheet-drawing-update import { SheetDrawingUIController } from './controllers/ui.controller'; import { touchSheetsDrawingFloatingHostCapabilityWhenReady } from './embed/floating-host'; import { DrawingPopupMenuController } from './menu/drawing-popup-menu.controller'; +import { SheetDrawingMenuController } from './menu/sheet-drawing-menu.controller'; import { BatchSaveImagesService, IBatchSaveImagesService } from './services/batch-save-images.service'; import { SheetCanvasFloatDomManagerService } from './services/canvas-float-dom-manager.service'; import { DrawingContextMenuService, IDrawingContextMenuService } from './services/drawing-context-menu.service'; @@ -98,6 +99,7 @@ export class UniverSheetsDrawingUIPlugin extends Plugin { [SheetCanvasFloatDomManagerService], [SheetDrawingHitTestService], [SheetDrawingUIController], + [SheetDrawingMenuController], [DrawingPopupMenuController], [SheetDrawingPrintingController], [SheetDrawingPermissionController], @@ -130,6 +132,7 @@ export class UniverSheetsDrawingUIPlugin extends Plugin { [SheetDrawingPermissionController], [SheetDrawingPrintingController], [SheetDrawingUIController], + [SheetDrawingMenuController], [SheetCellImageController], [SheetCellImageAutofillController], ]); diff --git a/packages/sheets-filter-ui/src/controllers/mobile-menu.controller.ts b/packages/sheets-filter-ui/src/controllers/mobile-menu.controller.ts index d0efe156fe..ce9ba774c4 100644 --- a/packages/sheets-filter-ui/src/controllers/mobile-menu.controller.ts +++ b/packages/sheets-filter-ui/src/controllers/mobile-menu.controller.ts @@ -17,7 +17,7 @@ import { Disposable, ICommandService } from '@univerjs/core'; import { SmartToggleSheetsFilterCommand } from '@univerjs/sheets-filter'; import { IMenuManagerService } from '@univerjs/ui'; -import { menuSchema } from '../menu/schema'; +import { mobileMenuSchema } from '../menu/mobile-schema'; export class SheetsFilterMobileMenuController extends Disposable { constructor( @@ -27,6 +27,6 @@ export class SheetsFilterMobileMenuController extends Disposable { super(); this.disposeWithMe(commandService.registerCommand(SmartToggleSheetsFilterCommand)); - menuManagerService.mergeMenu(menuSchema); + menuManagerService.mergeMenu(mobileMenuSchema); } } diff --git a/packages/sheets-filter-ui/src/menu/mobile-schema.ts b/packages/sheets-filter-ui/src/menu/mobile-schema.ts new file mode 100644 index 0000000000..3853bee648 --- /dev/null +++ b/packages/sheets-filter-ui/src/menu/mobile-schema.ts @@ -0,0 +1,45 @@ +/** + * 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 type { MenuSchemaType } from '@univerjs/ui'; +import { + ClearSheetsFilterCriteriaCommand, + ReCalcSheetsFilterCommand, + SmartToggleSheetsFilterCommand, +} from '@univerjs/sheets-filter'; +import { RibbonDataGroup } from '@univerjs/ui'; +import { + ClearFilterCriteriaMenuItemFactory, + ReCalcFilterMenuItemFactory, + SmartToggleFilterMenuItemFactory, +} from './sheets-filter.menu'; + +export const mobileMenuSchema: MenuSchemaType = { + [RibbonDataGroup.ORGANIZATION]: { + [SmartToggleSheetsFilterCommand.id]: { + order: 2, + menuItemFactory: SmartToggleFilterMenuItemFactory, + [ClearSheetsFilterCriteriaCommand.id]: { + order: 0, + menuItemFactory: ClearFilterCriteriaMenuItemFactory, + }, + [ReCalcSheetsFilterCommand.id]: { + order: 1, + menuItemFactory: ReCalcFilterMenuItemFactory, + }, + }, + }, +}; diff --git a/packages/sheets-filter-ui/src/views/mobile/MobileSheetsFilterPanel.tsx b/packages/sheets-filter-ui/src/views/mobile/MobileSheetsFilterPanel.tsx index 12a0200ce1..7002ba409f 100644 --- a/packages/sheets-filter-ui/src/views/mobile/MobileSheetsFilterPanel.tsx +++ b/packages/sheets-filter-ui/src/views/mobile/MobileSheetsFilterPanel.tsx @@ -32,7 +32,12 @@ import { } from '@univerjs/design'; import { MoreLeftIcon, MoreRightIcon } from '@univerjs/icons'; import { FilterBy, SheetsFilterSyncController } from '@univerjs/sheets-filter'; -import { IMessageService, MobileDrawer, useDependency, useObservable } from '@univerjs/ui'; +import { + IMessageService, + MobileDrawer, + useDependency, + useObservable, +} from '@univerjs/ui'; import { useRef, useState } from 'react'; import { map, of } from 'rxjs'; import { diff --git a/packages/sheets-formula-ui/src/controllers/ui.controller.ts b/packages/sheets-formula-ui/src/controllers/ui.controller.ts index 2f748059f4..061239b4e8 100644 --- a/packages/sheets-formula-ui/src/controllers/ui.controller.ts +++ b/packages/sheets-formula-ui/src/controllers/ui.controller.ts @@ -18,14 +18,13 @@ import type { Dependency } from '@univerjs/core'; import { Disposable, ICommandService, Inject, Injector, UniverInstanceType } from '@univerjs/core'; import { IRenderManagerService } from '@univerjs/engine-render'; import { SheetsUIPart } from '@univerjs/sheets-ui'; -import { connectInjector, IMenuManagerService, IShortcutService, IUIPartsService } from '@univerjs/ui'; +import { connectInjector, IShortcutService, IUIPartsService } from '@univerjs/ui'; import { SheetCopyFormulaOnlyCommand, SheetOnlyPasteFormulaCommand } from '../commands/commands/formula-clipboard.command'; import { HelpFunctionOperation } from '../commands/operations/help-function.operation'; import { InsertFunctionOperation } from '../commands/operations/insert-function.operation'; import { MoreFunctionsOperation } from '../commands/operations/more-functions.operation'; import { ReferenceAbsoluteOperation } from '../commands/operations/reference-absolute.operation'; import { SearchFunctionOperation } from '../commands/operations/search-function.operation'; -import { menuSchema } from '../menu/schema'; import { FormulaProgressBar } from '../views/formula-progress/FormulaProgress'; import { FormulaEditorShowController } from './formula-editor-show.controller'; import { ChangeRefToAbsoluteShortcut } from './shortcuts/prompt.shortcut'; @@ -34,7 +33,6 @@ import { QuickSumShortcut } from './shortcuts/quick-sum.shortcut'; export class FormulaUIController extends Disposable { constructor( @Inject(Injector) private readonly _injector: Injector, - @IMenuManagerService private readonly _menuManagerService: IMenuManagerService, @ICommandService private readonly _commandService: ICommandService, @IShortcutService private readonly _shortcutService: IShortcutService, @IUIPartsService private readonly _uiPartsService: IUIPartsService, @@ -47,16 +45,11 @@ export class FormulaUIController extends Disposable { private _initialize(): void { this._registerCommands(); - this._registerMenus(); this._registerShortcuts(); this._registerComponents(); this._registerRenderModules(); } - private _registerMenus(): void { - this._menuManagerService.mergeMenu(menuSchema); - } - private _registerCommands(): void { [ SheetCopyFormulaOnlyCommand, diff --git a/packages/sheets-formula-ui/src/menu/formula-menu.controller.ts b/packages/sheets-formula-ui/src/menu/formula-menu.controller.ts new file mode 100644 index 0000000000..00c105b081 --- /dev/null +++ b/packages/sheets-formula-ui/src/menu/formula-menu.controller.ts @@ -0,0 +1,26 @@ +/** + * 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 { Disposable } from '@univerjs/core'; +import { IMenuManagerService } from '@univerjs/ui'; +import { menuSchema } from './schema'; + +export class FormulaMenuController extends Disposable { + constructor(@IMenuManagerService menuManagerService: IMenuManagerService) { + super(); + menuManagerService.mergeMenu(menuSchema); + } +} diff --git a/packages/sheets-formula-ui/src/menu/mobile-formula-menu.controller.ts b/packages/sheets-formula-ui/src/menu/mobile-formula-menu.controller.ts new file mode 100644 index 0000000000..8a3128e851 --- /dev/null +++ b/packages/sheets-formula-ui/src/menu/mobile-formula-menu.controller.ts @@ -0,0 +1,26 @@ +/** + * 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 { Disposable } from '@univerjs/core'; +import { IMenuManagerService } from '@univerjs/ui'; +import { mobileMenuSchema } from './mobile-schema'; + +export class MobileFormulaMenuController extends Disposable { + constructor(@IMenuManagerService menuManagerService: IMenuManagerService) { + super(); + menuManagerService.mergeMenu(mobileMenuSchema); + } +} diff --git a/packages/sheets-formula-ui/src/menu/mobile-schema.ts b/packages/sheets-formula-ui/src/menu/mobile-schema.ts new file mode 100644 index 0000000000..73c362ddc5 --- /dev/null +++ b/packages/sheets-formula-ui/src/menu/mobile-schema.ts @@ -0,0 +1,103 @@ +/** + * 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 type { MenuSchemaType } from '@univerjs/ui'; +import { COPY_SPECIAL_MENU_ID, PASTE_SPECIAL_MENU_ID } from '@univerjs/sheets-ui'; +import { RibbonFormulasGroup } from '@univerjs/ui'; +import { SheetCopyFormulaOnlyCommand, SheetOnlyPasteFormulaCommand } from '../commands/commands/formula-clipboard.command'; +import { InsertFunctionOperation } from '../commands/operations/insert-function.operation'; +import { MoreFunctionsOperation } from '../commands/operations/more-functions.operation'; +import { + AllFunctionsMenuItemFactory, + CopyFormulaOnlyMenuItemFactory, + InsertCommonFunctionMenuItemFactory, + InsertDatabaseFunctionMenuItemFactory, + InsertDateFunctionMenuItemFactory, + InsertEngineeringFunctionMenuItemFactory, + InsertFinancialFunctionMenuItemFactory, + InsertInformationFunctionMenuItemFactory, + InsertLogicalFunctionMenuItemFactory, + InsertLookupFunctionMenuItemFactory, + InsertMathFunctionMenuItemFactory, + InsertStatisticalFunctionMenuItemFactory, + InsertTextFunctionMenuItemFactory, + PasteFormulaMenuItemFactory, +} from './menu'; + +export const mobileMenuSchema: MenuSchemaType = { + [RibbonFormulasGroup.BASIC]: { + [`${InsertFunctionOperation.id}.common`]: { + order: 0, + menuItemFactory: InsertCommonFunctionMenuItemFactory, + [MoreFunctionsOperation.id]: { + order: 0, + menuItemFactory: AllFunctionsMenuItemFactory, + }, + }, + [`${InsertFunctionOperation.id}.financial`]: { + order: 1, + menuItemFactory: InsertFinancialFunctionMenuItemFactory, + }, + [`${InsertFunctionOperation.id}.logical`]: { + order: 2, + menuItemFactory: InsertLogicalFunctionMenuItemFactory, + }, + [`${InsertFunctionOperation.id}.text`]: { + order: 3, + menuItemFactory: InsertTextFunctionMenuItemFactory, + }, + [`${InsertFunctionOperation.id}.date`]: { + order: 4, + menuItemFactory: InsertDateFunctionMenuItemFactory, + }, + [`${InsertFunctionOperation.id}.lookup`]: { + order: 5, + menuItemFactory: InsertLookupFunctionMenuItemFactory, + }, + [`${InsertFunctionOperation.id}.math`]: { + order: 6, + menuItemFactory: InsertMathFunctionMenuItemFactory, + }, + [`${InsertFunctionOperation.id}.statistical`]: { + order: 7, + menuItemFactory: InsertStatisticalFunctionMenuItemFactory, + }, + [`${InsertFunctionOperation.id}.engineering`]: { + order: 8, + menuItemFactory: InsertEngineeringFunctionMenuItemFactory, + }, + [`${InsertFunctionOperation.id}.information`]: { + order: 9, + menuItemFactory: InsertInformationFunctionMenuItemFactory, + }, + [`${InsertFunctionOperation.id}.database`]: { + order: 10, + menuItemFactory: InsertDatabaseFunctionMenuItemFactory, + }, + }, + [COPY_SPECIAL_MENU_ID]: { + [SheetCopyFormulaOnlyCommand.id]: { + order: 0, + menuItemFactory: CopyFormulaOnlyMenuItemFactory, + }, + }, + [PASTE_SPECIAL_MENU_ID]: { + [SheetOnlyPasteFormulaCommand.id]: { + order: 4, + menuItemFactory: PasteFormulaMenuItemFactory, + }, + }, +}; diff --git a/packages/sheets-formula-ui/src/mobile-plugin.ts b/packages/sheets-formula-ui/src/mobile-plugin.ts index 79e0939a3e..99b3422952 100644 --- a/packages/sheets-formula-ui/src/mobile-plugin.ts +++ b/packages/sheets-formula-ui/src/mobile-plugin.ts @@ -47,6 +47,7 @@ import { FormulaReorderController } from './controllers/formula-reorder.controll import { ImageFormulaRenderController } from './controllers/image-formula-render.controller'; import { MobileComponentsController } from './controllers/mobile/components.controller'; import { FormulaUIController } from './controllers/ui.controller'; +import { MobileFormulaMenuController } from './menu/mobile-formula-menu.controller'; import { FormulaPromptService, IFormulaPromptService } from './services/prompt.service'; import { GlobalRangeSelectorService } from './services/range-selector.service'; import { MobileRefSelectionsRenderService } from './services/render-services/mobile-ref-selections.render.service'; @@ -97,6 +98,7 @@ export class UniverSheetsFormulaMobileUIPlugin extends Plugin { [IFormulaPromptService, { useClass: FormulaPromptService }], [GlobalRangeSelectorService], [FormulaUIController], + [MobileFormulaMenuController], [FormulaClipboardController], [FormulaEditorShowController], [FormulaRenderManagerController], @@ -125,6 +127,7 @@ export class UniverSheetsFormulaMobileUIPlugin extends Plugin { touchDependencies(this._injector, [ [FormulaUIController], // FormulaProgressBar relies on TriggerCalculationController, but it is necessary to ensure that the formula calculation is done after rendered. + [MobileFormulaMenuController], [FormulaClipboardController], [FormulaRenderManagerController], [ImageFormulaRenderController], diff --git a/packages/sheets-formula-ui/src/plugin.ts b/packages/sheets-formula-ui/src/plugin.ts index ac99249efb..7233f2f0c8 100644 --- a/packages/sheets-formula-ui/src/plugin.ts +++ b/packages/sheets-formula-ui/src/plugin.ts @@ -47,6 +47,7 @@ import { FormulaRenderManagerController } from './controllers/formula-render.con import { FormulaReorderController } from './controllers/formula-reorder.controller'; import { ImageFormulaRenderController } from './controllers/image-formula-render.controller'; import { FormulaUIController } from './controllers/ui.controller'; +import { FormulaMenuController } from './menu/formula-menu.controller'; import { FormulaPromptService, IFormulaPromptService } from './services/prompt.service'; import { GlobalRangeSelectorService } from './services/range-selector.service'; import { RefSelectionsRenderService } from './services/render-services/ref-selections.render.service'; @@ -95,6 +96,7 @@ export class UniverSheetsFormulaUIPlugin extends Plugin { [IFormulaPromptService, { useClass: FormulaPromptService }], [GlobalRangeSelectorService], [FormulaUIController], + [FormulaMenuController], [FormulaClipboardController], [FormulaEditorShowController], [FormulaRenderManagerController], @@ -123,6 +125,7 @@ export class UniverSheetsFormulaUIPlugin extends Plugin { touchDependencies(this._injector, [ [FormulaUIController], // FormulaProgressBar relies on TriggerCalculationController, but it is necessary to ensure that the formula calculation is done after rendered. + [FormulaMenuController], [FormulaClipboardController], [FormulaRenderManagerController], [ImageFormulaRenderController], diff --git a/packages/sheets-formula-ui/src/views/mobile/formula-editor/MobileFormulaEditor.tsx b/packages/sheets-formula-ui/src/views/mobile/formula-editor/MobileFormulaEditor.tsx index 368192c379..2333a6c2de 100644 --- a/packages/sheets-formula-ui/src/views/mobile/formula-editor/MobileFormulaEditor.tsx +++ b/packages/sheets-formula-ui/src/views/mobile/formula-editor/MobileFormulaEditor.tsx @@ -352,8 +352,8 @@ export const MobileFormulaEditor = forwardRef((props: IMobileFormulaEditorProps, handledMobileFunctionPanelRequestRef.current = mobileFunctionPanelRequest; contextService.setContextValue(MOBILE_FORMULA_FUNCTION_PANEL_OPEN, true); setMobileFunctionPanelOpen(true); - editor?.blur(); - }, [contextService, editor, mobileFunctionPanelRequest]); + editorService.blur(); + }, [contextService, editorService, mobileFunctionPanelRequest]); useEffect(() => () => { contextService.setContextValue(MOBILE_FORMULA_FUNCTION_PANEL_OPEN, false); @@ -525,6 +525,11 @@ export const MobileFormulaEditor = forwardRef((props: IMobileFormulaEditorProps, let focusRetryFrame = 0; let finalFocusRetryFrame = 0; + if (mobileFunctionPanelOpen) { + editor?.blur(); + return undefined; + } + const retryFocus = () => { if (_isFocus && !docSelectionRenderService?.isFocusing) { focus(); @@ -551,7 +556,7 @@ export const MobileFormulaEditor = forwardRef((props: IMobileFormulaEditorProps, cancelAnimationFrame(focusRetryFrame); cancelAnimationFrame(finalFocusRetryFrame); }; - }, [_isFocus, docSelectionRenderService, editor, focus, resetSelection, resetSelectionOnBlur]); + }, [_isFocus, docSelectionRenderService, editor, focus, mobileFunctionPanelOpen, resetSelection, resetSelectionOnBlur]); const { checkScrollBar } = useResize(editor, isSingle, autoScrollbar); useRefactorEffect(isFocus, isSelecting, unitId, editorId, disableContextMenu, true); @@ -624,6 +629,7 @@ export const MobileFormulaEditor = forwardRef((props: IMobileFormulaEditorProps, const closeMobileFunctionPanel = () => { setMobileFunctionPanelOpen(false); requestAnimationFrame(() => { + setIsFocus(true); focus(); contextService.setContextValue(MOBILE_FORMULA_FUNCTION_PANEL_OPEN, false); }); @@ -680,6 +686,7 @@ export const MobileFormulaEditor = forwardRef((props: IMobileFormulaEditorProps, const currentText = BuildTextUtils.transform.getPlainText(editor.getDocumentData().body?.dataStream ?? ''); const currentSelection = editor.getSelectionRanges()?.[0]; const result = buildFormulaFunctionInsertion(currentText, currentSelection, functionName); + setIsFocus(true); applyMobileInsertion(result); setMobileFunctionPanelOpen(false); requestAnimationFrame(() => { diff --git a/packages/sheets-formula-ui/src/views/mobile/formula-editor/mobile-function-panel/MobileFunctionPanel.tsx b/packages/sheets-formula-ui/src/views/mobile/formula-editor/mobile-function-panel/MobileFunctionPanel.tsx index 41be4107ce..0807f7eab1 100644 --- a/packages/sheets-formula-ui/src/views/mobile/formula-editor/mobile-function-panel/MobileFunctionPanel.tsx +++ b/packages/sheets-formula-ui/src/views/mobile/formula-editor/mobile-function-panel/MobileFunctionPanel.tsx @@ -20,7 +20,7 @@ import { LocaleService } from '@univerjs/core'; import { clsx, scrollbarClassName } from '@univerjs/design'; import { FunctionType, IDescriptionService } from '@univerjs/engine-formula'; import { useDependency } from '@univerjs/ui'; -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { getFunctionTypeValues } from '../../../../services/utils'; const RECENT_FUNCTIONS_KEY = 'univer-mobile-recent-formula-functions'; @@ -69,6 +69,7 @@ export function MobileFunctionPanel(props: { const [category, setCategory] = useState('recommended'); const [details, setDetails] = useState(null); const [recentVersion, setRecentVersion] = useState(0); + const panelRef = useRef(null); useEffect(() => { if (!open) return; @@ -77,6 +78,15 @@ export function MobileFunctionPanel(props: { setDetails(null); }, [open]); + useLayoutEffect(() => { + if (!open) return undefined; + + const focusPanel = () => panelRef.current?.focus({ preventScroll: true }); + focusPanel(); + const focusFrame = requestAnimationFrame(focusPanel); + return () => cancelAnimationFrame(focusFrame); + }, [open]); + const categories = useMemo(() => { if (!open) return []; @@ -128,8 +138,10 @@ export function MobileFunctionPanel(props: { onPointerDown={(event) => event.stopPropagation()} >
+ ({ label: item, value: item }))} + onChange={setPattern} + />
-
- {patterns.map((item, index) => ( - {item}} - trailing={pattern === item - ? - : undefined} - bordered={index !== patterns.length - 1} - className="!univer-rounded-none !univer-font-normal" - onClick={() => setPattern(item)} - /> - ))} -
-
- {localeService.t('sheets-numfmt-ui.customFormatDes')} -
+ {localeService.t('sheets-numfmt-ui.customFormatDes')}
- + {addSheetButtonShow && ( + + )} ('univer export type ContextMenuQuickLayout = 'icon' | 'tile'; export type ContextMenuQuickLayoutVariant = 'default' | 'compact'; +export type MobileMenuLayout = 'grid' | 'list'; export interface IRibbonGridLayout { row: number; @@ -60,6 +61,7 @@ export interface IMenuSchema { quickLayout?: ContextMenuQuickLayout; quickColumns?: number; quickLayoutVariant?: ContextMenuQuickLayoutVariant; + mobileLayout?: MobileMenuLayout; tiny?: boolean; gridLayout?: IRibbonGridLayout; } @@ -86,6 +88,7 @@ export type MenuSchemaType = { quickLayout?: ContextMenuQuickLayout; quickColumns?: number; quickLayoutVariant?: ContextMenuQuickLayoutVariant; + mobileLayout?: MobileMenuLayout; tiny?: boolean; gridLayout?: IRibbonGridLayout; } | { @@ -360,6 +363,7 @@ export class MenuManagerService extends Disposable implements IMenuManagerServic quickLayout: value.quickLayout, quickColumns: value.quickColumns, quickLayoutVariant: value.quickLayoutVariant, + mobileLayout: value.mobileLayout, tiny: value.tiny, gridLayout: value.gridLayout, }; diff --git a/packages/ui/src/views/components/context-menu/MobileContextMenu.tsx b/packages/ui/src/views/components/context-menu/MobileContextMenu.tsx index 3498f2513f..b2c469addd 100644 --- a/packages/ui/src/views/components/context-menu/MobileContextMenu.tsx +++ b/packages/ui/src/views/components/context-menu/MobileContextMenu.tsx @@ -49,6 +49,10 @@ export function resolveMobileCaretMenuPlacement(anchorX: number, containerLeft: return { menuLeft, pointerLeft }; } +export function shouldUseFloatingMobileContextMenu(_menuType: string, context?: IContextMenuTriggerContext): boolean { + return context?.caretAnchor === true; +} + export function MobileContextMenu() { const [visible, setVisible] = useState(false); const [menuType, setMenuType] = useState(''); @@ -65,7 +69,7 @@ export function MobileContextMenu() { const localeService = useDependency(LocaleService); const { mountContainer } = useContext(ConfigContext); const isCaretAnchor = menuContext?.caretAnchor === true; - const isFloatingContextMenu = menuType === ContextMenuPosition.MAIN_AREA || isCaretAnchor; + const isFloatingContextMenu = shouldUseFloatingMobileContextMenu(menuType, menuContext); visibleRef.current = visible; diff --git a/packages/ui/src/views/components/context-menu/__tests__/MobileContextMenu.spec.ts b/packages/ui/src/views/components/context-menu/__tests__/MobileContextMenu.spec.ts index 482d5d9883..fb41b59bbf 100644 --- a/packages/ui/src/views/components/context-menu/__tests__/MobileContextMenu.spec.ts +++ b/packages/ui/src/views/components/context-menu/__tests__/MobileContextMenu.spec.ts @@ -15,9 +15,19 @@ */ import { describe, expect, it } from 'vitest'; -import { resolveMobileCaretMenuPlacement } from '../MobileContextMenu'; +import { ContextMenuPosition } from '../../../../services/menu/types'; +import { resolveMobileCaretMenuPlacement, shouldUseFloatingMobileContextMenu } from '../MobileContextMenu'; describe('MobileContextMenu', () => { + it('uses a floating menu only for an explicit caret anchor', () => { + expect(shouldUseFloatingMobileContextMenu(ContextMenuPosition.MAIN_AREA)).toBe(false); + expect(shouldUseFloatingMobileContextMenu(ContextMenuPosition.MAIN_AREA, { unitId: 'sheet-1' })).toBe(false); + expect(shouldUseFloatingMobileContextMenu(ContextMenuPosition.MAIN_AREA, { + unitId: 'doc-1', + caretAnchor: true, + })).toBe(true); + }); + it('keeps the pointer aligned with the caret when the compact menu reaches viewport edges', () => { expect(resolveMobileCaretMenuPlacement(200, 12, 400, 180)).toEqual({ menuLeft: 98, diff --git a/packages/ui/src/views/components/dialog-part/MobileDialogPart.tsx b/packages/ui/src/views/components/dialog-part/MobileDialogPart.tsx index 51b34a4619..8bb85dbd8d 100644 --- a/packages/ui/src/views/components/dialog-part/MobileDialogPart.tsx +++ b/packages/ui/src/views/components/dialog-part/MobileDialogPart.tsx @@ -20,9 +20,9 @@ import type { MobilePanelLayout } from '../../mobile-workbench/MobileCanvasLayou import type { MobileDrawerSnap } from '../mobile-drawer/MobileDrawer'; import type { IDialogPartMethodOptions } from './interface'; import { LocaleService } from '@univerjs/core'; -import { MobileActionRowGroup } from '@univerjs/design'; +import { Button, ConfigContext, MobileActionRowGroup } from '@univerjs/design'; import { CloseIcon } from '@univerjs/icons'; -import { useMemo, useRef, useState } from 'react'; +import { useContext, useMemo, useRef, useState } from 'react'; import { IDialogService } from '../../../services/dialog/dialog.service'; import { useDependency, useObservable } from '../../../utils/di'; import { CustomLabel } from '../../custom-label/CustomLabel'; @@ -51,6 +51,7 @@ function toMobileDialogOptions(options: IMobileDialogPartMethodOptions): IMobile export function MobileDialogPart() { const dialogService = useDependency(IDialogService); const localeService = useDependency(LocaleService); + const { locale } = useContext(ConfigContext); const dialogOptions = useObservable(dialogService.getDialogs$(), []); const options = useMemo(() => { const activeDialogs = dialogOptions.filter((item) => item.open !== false); @@ -65,6 +66,14 @@ export function MobileDialogPart() { return null; } const defaultSnap = options.layout === 'canvas' ? 'compact' : 'expanded'; + const footer = options.footer ?? (options.showOk || options.showCancel + ? ( + <> + {options.showCancel && } + {options.showOk && } + + ) + : null); const close = () => { dialogService.close(options.id); @@ -147,7 +156,7 @@ export function MobileDialogPart() { )} )} - footer={options.footer + footer={footer ? (
- {options.footer} + {footer}
) : undefined} diff --git a/packages/ui/src/views/components/dialog-part/__tests__/MobileDialogPart.spec.tsx b/packages/ui/src/views/components/dialog-part/__tests__/MobileDialogPart.spec.tsx index eb218b3ba8..0e902dca42 100644 --- a/packages/ui/src/views/components/dialog-part/__tests__/MobileDialogPart.spec.tsx +++ b/packages/ui/src/views/components/dialog-part/__tests__/MobileDialogPart.spec.tsx @@ -17,6 +17,8 @@ import type { ReactElement } from 'react'; import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'; import { DesktopLogService, ILogService, Injector, LocaleService, LocaleType } from '@univerjs/core'; +import { ConfigProvider } from '@univerjs/design'; +import designEnUS from '@univerjs/design/locale/en-US'; import { useState } from 'react'; import { createPortal } from 'react-dom'; import { afterEach, describe, expect, it, vi } from 'vitest'; @@ -33,7 +35,7 @@ import { MobileDialogPart } from '../MobileDialogPart'; Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true, writable: true }); -function renderWithDependencies(element: ReactElement, mobileService = false) { +function renderWithDependencies(element: ReactElement, mobileService = false, keyboardVisible = false) { const injector = new Injector(); injector.add([IUIPartsService, { useClass: UIPartsService }]); injector.add([IDialogService, { useClass: mobileService ? MobileDialogService : DesktopDialogService }]); @@ -46,9 +48,17 @@ function renderWithDependencies(element: ReactElement, mobileService = false) { return { ...render( - - {element} - + + + {element} + + ), injector, }; @@ -109,6 +119,20 @@ describe('MobileDialogPart', () => { expect(screen.getByText('Actions')).toBeTruthy(); }); + it('keeps the active dialog anchored while the mobile keyboard is visible', () => { + vi.stubGlobal('innerHeight', 768); + const rendered = renderWithDependencies(, false, true); + const dialogService = rendered.injector.get(IDialogService); + + act(() => { + dialogService.open({ id: 'keyboard-aware', children: { title: } }); + }); + + const drawer = screen.getByRole('dialog'); + expect(drawer.style.bottom).toBe(''); + expect(drawer.style.maxHeight).toBe(''); + }); + it('closes with the close button and invokes dialog callbacks', () => { const rendered = renderWithDependencies(); const dialogService = rendered.injector.get(IDialogService); @@ -146,6 +170,30 @@ describe('MobileDialogPart', () => { expect(screen.getByRole('dialog')).toBeTruthy(); }); + it('renders legacy confirm actions in the mobile footer', () => { + const rendered = renderWithDependencies(); + const dialogService = rendered.injector.get(IDialogService); + const onCancel = vi.fn(); + const onOk = vi.fn(); + + act(() => { + dialogService.open({ + id: 'legacy-confirm', + children: { title: Confirm action }, + showCancel: true, + showOk: true, + onCancel, + onOk, + }); + }); + + fireEvent.click(screen.getByRole('button', { name: 'cancel' })); + fireEvent.click(screen.getByRole('button', { name: 'ok' })); + + expect(onCancel).toHaveBeenCalledOnce(); + expect(onOk).toHaveBeenCalledOnce(); + }); + it('ignores the release of the pointer that opened the dialog before accepting a backdrop tap', () => { const rendered = renderWithDependencies( <> diff --git a/packages/ui/src/views/components/mobile-drawer/MobileDrawer.tsx b/packages/ui/src/views/components/mobile-drawer/MobileDrawer.tsx index 974ac5b539..a043620a01 100644 --- a/packages/ui/src/views/components/mobile-drawer/MobileDrawer.tsx +++ b/packages/ui/src/views/components/mobile-drawer/MobileDrawer.tsx @@ -14,9 +14,15 @@ * limitations under the License. */ -import type { AriaRole, PointerEvent, ReactNode, RefObject } from 'react'; +import type { AriaRole, PointerEvent, ReactNode, RefObject, TouchEvent } from 'react'; import type { MobilePanelLayout } from '../../mobile-workbench/MobileCanvasLayout'; -import { clsx, MobileOverlayContext, resetButtonClassName, scrollbarClassName } from '@univerjs/design'; +import { + clsx, + MobileOverlayContext, + resetButtonClassName, + scrollbarClassName, + useMobileKeyboardViewportLayout, +} from '@univerjs/design'; import { createContext, useCallback, useContext, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useMobileCanvasPanel, useMobileOverlayRegistration } from '../../mobile-workbench/MobileCanvasLayout'; import { MobileDrawerCoordinatorContext } from './MobileDrawerCoordinator'; @@ -46,7 +52,7 @@ export function resolveMobileDrawerRelease(params: { }): MobileDrawerRelease { const { snap, deltaY, durationMs, percent } = params; const velocity = deltaY / Math.max(durationMs, 1); - if ((deltaY > 48 && velocity > 0.55) || percent < 20) return 'closed'; + if ((deltaY > 48 && (snap === 'compact' || velocity > 0.55)) || percent < 20) return 'closed'; if (snap === 'expanded' && deltaY > 24) return 'compact'; if (snap === 'compact' && deltaY < -24) return 'expanded'; return percent >= 60 ? 'expanded' : 'compact'; @@ -140,11 +146,13 @@ export function MobileDrawer(props: { startTime: number; startPercent: number; currentPercent: number; + currentY: number; moved: boolean; } | null>(null); const drawerPercent = dragPercent ?? (snap === 'compact' ? MOBILE_DRAWER_COMPACT_PERCENT : MOBILE_DRAWER_EXPANDED_PERCENT); + const keyboardLayout = useMobileKeyboardViewportLayout(panelElementRef); useLayoutEffect(() => { if (!registerDrawer || !unregisterDrawer) { @@ -170,6 +178,7 @@ export function MobileDrawer(props: { startTime: performance.now(), startPercent: drawerPercent, currentPercent: drawerPercent, + currentY: clientY, moved: false, }; } @@ -184,6 +193,7 @@ export function MobileDrawer(props: { drag.startPercent - deltaY / Math.max(window.innerHeight, 1) * 100 )); drag.currentPercent = nextPercent; + drag.currentY = clientY; drag.moved ||= Math.abs(deltaY) > 6; setDragPercent(nextPercent); } @@ -230,8 +240,39 @@ export function MobileDrawer(props: { } function handlePointerCancel() { - dragRef.current = null; - setDragPercent(null); + const drag = dragRef.current; + if (!drag?.moved) { + dragRef.current = null; + setDragPercent(null); + return; + } + + endDrag(drag.currentY); + } + + function handleTouchStart(event: TouchEvent) { + const touch = event.touches[0]; + if (touch) { + beginDrag(touch.clientY); + } + } + + function handleTouchMove(event: TouchEvent) { + const touch = event.touches[0]; + if (touch) { + moveDrag(touch.clientY); + } + } + + function handleTouchEnd() { + const drag = dragRef.current; + if (drag) { + endDrag(drag.currentY); + } + } + + function handleTouchCancel() { + handlePointerCancel(); } return ( @@ -283,10 +324,15 @@ export function MobileDrawer(props: { univer-w-16 -univer-translate-x-1/2 univer-touch-none univer-items-center univer-justify-center `)} + style={{ touchAction: 'none' }} onPointerDown={handlePointerDown} onPointerMove={handlePointerMove} onPointerUp={handlePointerUp} onPointerCancel={handlePointerCancel} + onTouchStart={handleTouchStart} + onTouchMove={handleTouchMove} + onTouchEnd={handleTouchEnd} + onTouchCancel={handleTouchCancel} onClick={() => { if (suppressHandleClickRef.current) { suppressHandleClickRef.current = false; @@ -311,6 +357,11 @@ export function MobileDrawer(props: { scrollbarClassName, contentClassName )} + style={{ + paddingBottom: keyboardLayout + ? `calc(0.75rem + ${keyboardLayout.bottom}px)` + : undefined, + }} > {children} diff --git a/packages/ui/src/views/components/mobile-drawer/__tests__/MobileDrawer.spec.tsx b/packages/ui/src/views/components/mobile-drawer/__tests__/MobileDrawer.spec.tsx index aae05c8762..73e90ecb62 100644 --- a/packages/ui/src/views/components/mobile-drawer/__tests__/MobileDrawer.spec.tsx +++ b/packages/ui/src/views/components/mobile-drawer/__tests__/MobileDrawer.spec.tsx @@ -104,10 +104,48 @@ describe('mobile drawer snap behavior', () => { expect(container.querySelector('section')?.style.height).toBe('40vh'); }); - it('supports expand, collapse, restore, and fast close gestures', () => { + it('keeps the drawer anchored and adds keyboard space only to its scroll area', () => { + vi.stubGlobal('CSS', { supports: () => false }); + vi.stubGlobal('innerHeight', 768); + vi.stubGlobal('visualViewport', { + height: 448, + offsetTop: 0, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }); + const { container } = render( + + Floating action} + > + Drawer content + + + ); + + const drawer = container.querySelector('section'); + expect(drawer?.style.bottom).toBe(''); + expect(drawer?.style.height).toBe('40vh'); + expect(drawer?.style.maxHeight).toBe(''); + expect(drawer?.querySelector('.univer-overflow-y-auto')?.style.paddingBottom) + .toBe('calc(0.75rem + 320px)'); + expect(screen.getByRole('button', { name: 'Floating action' }).parentElement?.style.bottom) + .toBe('calc(40vh + 12px)'); + }); + + it('supports expand, collapse, restore, and close gestures', () => { expect(resolveMobileDrawerRelease({ snap: 'compact', deltaY: -40, durationMs: 300, percent: 45 })).toBe('expanded'); expect(resolveMobileDrawerRelease({ snap: 'expanded', deltaY: 40, durationMs: 300, percent: 75 })).toBe('compact'); expect(resolveMobileDrawerRelease({ snap: 'compact', deltaY: 8, durationMs: 300, percent: 39 })).toBe('compact'); + expect(resolveMobileDrawerRelease({ snap: 'compact', deltaY: 80, durationMs: 500, percent: 31 })).toBe('closed'); expect(resolveMobileDrawerRelease({ snap: 'compact', deltaY: 120, durationMs: 150, percent: 25 })).toBe('closed'); }); @@ -132,7 +170,7 @@ describe('mobile drawer snap behavior', () => { expect(onClose).not.toHaveBeenCalled(); }); - it('reserves the drag handle for pointer gestures and cancels without changing the drawer', () => { + it('cancels without changing the drawer when the pointer has not moved', () => { const onSnapChange = vi.fn(); const onClose = vi.fn(); render(createElement(MobileDrawer, { @@ -145,14 +183,34 @@ describe('mobile drawer snap behavior', () => { const handle = screen.getByRole('button', { name: 'Expand drawer' }); expect(handle.classList.contains('univer-touch-none')).toBe(true); + expect(handle.style.touchAction).toBe('none'); fireEvent.pointerDown(handle, { pointerId: 1, clientY: 500 }); - fireEvent.pointerMove(handle, { pointerId: 1, clientY: 450 }); fireEvent.pointerCancel(handle, { pointerId: 1, clientY: 0 }); expect(onSnapChange).not.toHaveBeenCalled(); expect(onClose).not.toHaveBeenCalled(); }); + it('settles an active drag at the last pointer position when the pointer is cancelled', () => { + const onSnapChange = vi.fn(); + const onClose = vi.fn(); + render(createElement(MobileDrawer, { + snap: 'compact', + expandLabel: 'Expand drawer', + collapseLabel: 'Collapse drawer', + onSnapChange, + onClose, + }, 'Drawer content')); + const handle = screen.getByRole('button', { name: 'Expand drawer' }); + + fireEvent.pointerDown(handle, { pointerId: 1, clientY: 500 }); + fireEvent.pointerMove(handle, { pointerId: 1, clientY: 650 }); + fireEvent.pointerCancel(handle, { pointerId: 1, clientY: 0 }); + + expect(onClose).toHaveBeenCalledOnce(); + expect(onSnapChange).not.toHaveBeenCalled(); + }); + it('closes when the compact handle is flicked downward', () => { const onSnapChange = vi.fn(); const onClose = vi.fn(); @@ -173,6 +231,26 @@ describe('mobile drawer snap behavior', () => { expect(onSnapChange).not.toHaveBeenCalled(); }); + it('closes from a touch gesture without relying on pointer events', () => { + const onSnapChange = vi.fn(); + const onClose = vi.fn(); + render(createElement(MobileDrawer, { + snap: 'compact', + expandLabel: 'Expand drawer', + collapseLabel: 'Collapse drawer', + onSnapChange, + onClose, + }, 'Drawer content')); + const handle = screen.getByRole('button', { name: 'Expand drawer' }); + + fireEvent.touchStart(handle, { touches: [{ clientY: 500 }] }); + fireEvent.touchMove(handle, { touches: [{ clientY: 570 }] }); + fireEvent.touchEnd(handle, { changedTouches: [{ clientY: 570 }] }); + + expect(onClose).toHaveBeenCalledOnce(); + expect(onSnapChange).not.toHaveBeenCalled(); + }); + it('closes the active drawer and suspended parents when another root drawer opens', () => { const closeParent = vi.fn(); const closeChild = vi.fn(); diff --git a/packages/ui/src/views/menu/mobile/MobileMenu.tsx b/packages/ui/src/views/menu/mobile/MobileMenu.tsx index ad8ba8b798..8833ca710d 100644 --- a/packages/ui/src/views/menu/mobile/MobileMenu.tsx +++ b/packages/ui/src/views/menu/mobile/MobileMenu.tsx @@ -456,7 +456,10 @@ function MobileSchemaList(props: { return null; } - if (schema.quickLayout) { + const useGridLayout = schema.mobileLayout === 'grid' + || (schema.mobileLayout === undefined && Boolean(schema.quickLayout)); + + if (useGridLayout) { return ( { expect(onOptionSelect).toHaveBeenCalledWith(expect.objectContaining({ id: 'image' })); }); + it('uses an explicit mobile list layout even when the desktop quick layout is present', () => { + renderWithDependencies([{ + key: 'quick', + order: 0, + quickLayout: 'tile', + mobileLayout: 'list', + children: [{ + key: 'copy', + order: 0, + item: { id: 'copy', type: MenuItemType.BUTTON, title: 'Copy' }, + }, { + key: 'cut', + order: 1, + item: { id: 'cut', type: MenuItemType.BUTTON, title: 'Cut' }, + }], + }], vi.fn()); + + const copy = screen.getByRole('button', { name: 'Copy' }); + const cut = screen.getByRole('button', { name: 'Cut' }); + expect(copy.className).toContain('univer-min-h-12'); + expect(cut.className).toContain('univer-min-h-12'); + expect(copy.parentElement?.className).toContain('univer-grid'); + expect(copy.parentElement?.getAttribute('style')).toBeNull(); + }); + it('renders grouped context menu items as a horizontal text-only bar', () => { const onOptionSelect = vi.fn(); renderWithDependencies([{ diff --git a/packages/ui/src/views/mobile-workbench/MobileWorkbench.tsx b/packages/ui/src/views/mobile-workbench/MobileWorkbench.tsx index fab5c6e14d..2a36d54f36 100644 --- a/packages/ui/src/views/mobile-workbench/MobileWorkbench.tsx +++ b/packages/ui/src/views/mobile-workbench/MobileWorkbench.tsx @@ -15,10 +15,17 @@ */ import type { Injector } from '@univerjs/core'; +import type { IMobileKeyboardViewport } from '@univerjs/design'; import type { ComponentType } from 'react'; import type { IWorkbenchOptions } from '../../controllers/ui/ui.controller'; import { LifecycleService, LifecycleStages, LocaleService, ThemeService } from '@univerjs/core'; -import { borderBottomClassName, clsx, ConfigProvider, render } from '@univerjs/design'; +import { + borderBottomClassName, + clsx, + ConfigProvider, + render, + revealFocusedElementInMobileViewport, +} from '@univerjs/design'; import { connectInjector, useDependency, useObservable } from '@wendellhu/redi/react-bindings'; import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { map } from 'rxjs'; @@ -98,6 +105,7 @@ export function MobileWorkbench(props: IUniverAppProps) { const canvasContainerRef = useRef(null); const viewportRef = useRef(null); const [keyboardInset, setKeyboardInset] = useState(0); + const [keyboardViewport, setKeyboardViewport] = useState(null); const footerComponents = useComponentsOfPart(BuiltInUIPart.FOOTER); const headerComponents = useComponentsOfPart(BuiltInUIPart.HEADER); @@ -168,6 +176,9 @@ export function MobileWorkbench(props: IUniverAppProps) { visualViewport ? visualViewport.offsetTop + visualViewport.height : window.innerHeight )); let keyboardVisible = false; + let initialViewportFrame: number | null = null; + let revealFrame: number | null = null; + let revealInnerFrame: number | null = null; const isEditing = () => { const activeElement = document.activeElement; @@ -176,9 +187,35 @@ export function MobileWorkbench(props: IUniverAppProps) { activeElement?.getAttribute('contenteditable') === 'true'; }; - const updateKeyboardInset = () => { + const cancelReveal = () => { + if (revealFrame != null) { + cancelAnimationFrame(revealFrame); + revealFrame = null; + } + if (revealInnerFrame != null) { + cancelAnimationFrame(revealInnerFrame); + revealInnerFrame = null; + } + }; + const scheduleReveal = (viewport: IMobileKeyboardViewport | null) => { + cancelReveal(); + if (!viewport) { + return; + } + + revealFrame = requestAnimationFrame(() => { + revealFrame = null; + revealInnerFrame = requestAnimationFrame(() => { + revealInnerFrame = null; + revealFocusedElementInMobileViewport(document.body, viewport); + }); + }); + }; + const updateKeyboardViewport = () => { + const visibleTop = visualViewport?.offsetTop ?? 0; + const visibleHeight = visualViewport?.height ?? window.innerHeight; const visibleBottom = visualViewport - ? visualViewport.offsetTop + visualViewport.height + ? visibleTop + visibleHeight : window.innerHeight; const viewportInset = Math.max(0, Math.round(stableHeight - visibleBottom)); const keyboardInset = resolveMobileKeyboardInset(stableHeight, visibleBottom, baselineInset); @@ -201,7 +238,18 @@ export function MobileWorkbench(props: IUniverAppProps) { `${keyboardVisible ? keyboardInset : 0}px` ); - setKeyboardInset(keyboardVisible ? keyboardInset : 0); + const nextKeyboardInset = keyboardVisible ? keyboardInset : 0; + const nextKeyboardViewport = keyboardVisible + ? { + top: Math.round(visibleTop), + bottom: Math.round(visibleBottom), + height: Math.round(visibleHeight), + stableHeight, + } + : null; + setKeyboardInset(nextKeyboardInset); + setKeyboardViewport(nextKeyboardViewport); + scheduleReveal(nextKeyboardViewport); }; const updateStableViewport = () => { const height = Math.round(mountContainer.getBoundingClientRect().height || window.innerHeight); @@ -220,19 +268,28 @@ export function MobileWorkbench(props: IUniverAppProps) { stableWidth = width; viewportElement.style.height = `${stableHeight}px`; } - updateKeyboardInset(); + updateKeyboardViewport(); }; viewportElement.style.height = `${stableHeight}px`; - updateKeyboardInset(); + initialViewportFrame = requestAnimationFrame(() => { + initialViewportFrame = null; + updateKeyboardViewport(); + }); window.addEventListener('resize', updateStableViewport); - visualViewport?.addEventListener('resize', updateKeyboardInset); - visualViewport?.addEventListener('scroll', updateKeyboardInset); + visualViewport?.addEventListener('resize', updateKeyboardViewport); + visualViewport?.addEventListener('scroll', updateKeyboardViewport); + document.addEventListener('focusin', updateKeyboardViewport); return () => { + if (initialViewportFrame != null) { + cancelAnimationFrame(initialViewportFrame); + } + cancelReveal(); window.removeEventListener('resize', updateStableViewport); - visualViewport?.removeEventListener('resize', updateKeyboardInset); - visualViewport?.removeEventListener('scroll', updateKeyboardInset); + visualViewport?.removeEventListener('resize', updateKeyboardViewport); + visualViewport?.removeEventListener('scroll', updateKeyboardViewport); + document.removeEventListener('focusin', updateKeyboardViewport); }; }, [mountContainer]); @@ -242,6 +299,7 @@ export function MobileWorkbench(props: IUniverAppProps) { locale={locale?.design} direction={direction} mountContainer={portalContainer} + mobileKeyboardViewport={keyboardViewport} > diff --git a/packages/ui/src/views/mobile-workbench/__tests__/MobileWorkbench.spec.tsx b/packages/ui/src/views/mobile-workbench/__tests__/MobileWorkbench.spec.tsx index 22ba61121d..b9fbea688e 100644 --- a/packages/ui/src/views/mobile-workbench/__tests__/MobileWorkbench.spec.tsx +++ b/packages/ui/src/views/mobile-workbench/__tests__/MobileWorkbench.spec.tsx @@ -34,6 +34,7 @@ import { ThemeService, UniverInstanceService, } from '@univerjs/core'; +import { ConfigContext } from '@univerjs/design'; import { connectInjector } from '@wendellhu/redi/react-bindings'; import { useContext } from 'react'; import { createPortal } from 'react-dom'; @@ -48,7 +49,14 @@ import { MobileWorkbench, resolveMobileKeyboardInset, shouldUpdateMobileStableHe function KeyboardBar() { const bottom = useContext(MobileKeyboardInsetContext); - return
; + const { mobileKeyboardViewport } = useContext(ConfigContext); + return ( +
+ ); } describe('MobileWorkbench keyboard positioning', () => { @@ -130,6 +138,8 @@ describe('MobileWorkbench keyboard positioning', () => { expect(secondBar.style.bottom).toBe('100px'); expect(portalBars[0].style.bottom).toBe('300px'); expect(portalBars[1].style.bottom).toBe('100px'); + expect(firstBar.dataset.viewport).toBe(JSON.stringify({ top: 0, bottom: 500, height: 500, stableHeight: 800 })); + expect(secondBar.dataset.viewport).toBe(JSON.stringify({ top: 0, bottom: 500, height: 500, stableHeight: 600 })); act(() => { viewport.offsetTop = 20; @@ -137,6 +147,7 @@ describe('MobileWorkbench keyboard positioning', () => { }); expect(firstBar.style.bottom).toBe('280px'); expect(secondBar.style.bottom).toBe('80px'); + expect(firstBar.dataset.viewport).toBe(JSON.stringify({ top: 20, bottom: 520, height: 500, stableHeight: 800 })); act(() => { viewport.height = 800;