feat: improve mobile sheet interactions (#7702)

This commit is contained in:
wpxp123456
2026-09-18 13:01:03 +08:00
committed by GitHub
parent c7e3fb2a90
commit 75d1e1076f
71 changed files with 3495 additions and 517 deletions
-1
View File
@@ -1,6 +1,5 @@
[*]
charset=utf-8
end_of_line=lf
indent_size=4
indent_style=space
insert_final_newline=true
@@ -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<Omit<IConfigProviderProps, 'children'>>({
@@ -31,15 +39,16 @@ export const ConfigContext = createContext<Omit<IConfigProviderProps, 'children'
});
export function ConfigProvider(props: IConfigProviderProps) {
const { children, locale, mountContainer, direction } = props;
const { children, locale, mountContainer, direction, mobileKeyboardViewport } = props;
const value = useMemo(() => {
return {
locale,
direction,
mountContainer,
mobileKeyboardViewport,
};
}, [locale, direction, mountContainer]);
}, [locale, direction, mobileKeyboardViewport, mountContainer]);
return (
<ConfigContext.Provider value={value}>
@@ -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(
<ConfigProvider
mountContainer={document.body}
mobileKeyboardViewport={{ top: 0, bottom: 448, height: 448, stableHeight: 768 }}
>
<MobileDialog open title="Edit">Content</MobileDialog>
</ConfigProvider>
);
const dialog = screen.getByRole('dialog');
expect(dialog.style.bottom).toBe('0px');
expect(dialog.style.maxHeight).toBe('80vh');
expect(dialog.querySelector<HTMLElement>('.univer-overflow-y-auto')?.style.paddingBottom).toBe('');
});
});
@@ -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' });
});
});
@@ -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<IMobileKeyboardViewport, 'top' | 'bottom'>
): 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<HTMLElement | null>,
heightRatio?: number
): IMobileKeyboardViewportLayout | null {
const { mobileKeyboardViewport } = useContext(ConfigContext);
const [layout, updateLayout] = useReducer(
(_current: IMobileKeyboardViewportLayout | null, next: IMobileKeyboardViewportLayout | null) => next,
null
);
const layoutRef = useRef<IMobileKeyboardViewportLayout | null>(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;
}
+7 -1
View File
@@ -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';
@@ -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<Editor | null>(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,
}}
@@ -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(),
<ConfigProvider mountContainer={document.body}>
<MobileImagePopupMenu
popup={{
extraProps: {
menuItems: [{
label: 'drawing.edit',
index: 0,
commandId: chartEditCommandId,
commandParams: { source: 'first-action' },
disable: false,
}, {
label: 'drawing.delete',
index: 1,
commandId: chartEditCommandId,
commandParams: { source: 'second-action' },
disable: false,
}],
},
}}
/>
</ConfigProvider>
);
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));
@@ -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 (
<div className="univer-flex univer-flex-col univer-gap-2">
{menuItems.map((item) => {
const label = localeService.t<LocaleKey>(item.label as LocaleKey);
const Icon = item.icon && iconManager.has(item.icon) ? iconManager.get(item.icon) : null;
return (
<MobileActionRow
key={`${item.commandId}-${item.label}`}
title={label}
aria-label={label}
icon={Icon ? <Icon /> : undefined}
variant="subtle"
disabled={item.disable}
onClick={async () => {
await executeMenuItem(item);
}}
/>
);
})}
</div>
);
}
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);
}}
/>
);
@@ -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<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const suppressNextFocusRef = useRef(false);
const keyboardLayout = useMobileKeyboardViewportLayout(barRef);
useEffect(() => {
const frame = requestAnimationFrame(() => inputRef.current?.focus());
@@ -159,6 +160,7 @@ function MobileFindReplaceBarContent() {
return (
<div
ref={barRef}
data-u-comp="mobile-find-replace-bar"
className="
univer-fixed univer-inset-x-0 univer-z-40 univer-grid univer-gap-2 univer-bg-gray-0 univer-px-2
@@ -166,7 +168,7 @@ function MobileFindReplaceBarContent() {
dark:!univer-bg-gray-800
"
style={{
bottom: keyboardInset,
bottom: keyboardLayout?.bottom ?? 0,
paddingBottom: 'calc(6px + env(safe-area-inset-bottom, 0px))',
}}
>
@@ -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);
@@ -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<Workbook>(unitId, UniverInstanceType.UNIVER_SHEET)
?? this._univerInstanceService.getUnit<BaseDataModel>(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$<Workbook>(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<unknown>, operation: string): void {
request.catch((error: unknown) => {
if (error instanceof ChannelClientDisposedError) {
return;
}
this._logService.error(`[DataSyncPrimaryController]: Failed to ${operation}.`, error);
});
}
}
@@ -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!',
}));
+12 -3
View File
@@ -124,6 +124,15 @@ export interface IChannelServer {
registerChannel<T extends IChannel>(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);
@@ -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,
},
},
};
@@ -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();
}
@@ -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,
},
},
},
};
@@ -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);
}
}
@@ -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);
}
}
@@ -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],
]);
+3
View File
@@ -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],
]);
@@ -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);
}
}
@@ -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,
},
},
},
};
@@ -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 {
@@ -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,
@@ -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);
}
}
@@ -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);
}
}
@@ -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,
},
},
};
@@ -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],
+3
View File
@@ -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],
@@ -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(() => {
@@ -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<MobileFunctionCategory>('recommended');
const [details, setDetails] = useState<IFunctionInfo | null>(null);
const [recentVersion, setRecentVersion] = useState(0);
const panelRef = useRef<HTMLElement>(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()}
>
<section
ref={panelRef}
role="dialog"
aria-label={copy.title}
tabIndex={-1}
className="
univer-absolute univer-inset-x-0 univer-bottom-0 univer-flex univer-h-[80vh] univer-flex-col
univer-overflow-hidden univer-rounded-t-2xl univer-bg-gray-0 univer-shadow-lg
@@ -15,7 +15,7 @@
*/
import { Disposable, ICommandService, Inject } from '@univerjs/core';
import { IMenuManagerService, IShortcutService } from '@univerjs/ui';
import { IShortcutService } from '@univerjs/ui';
import {
CloseHyperLinkPopupOperation,
InsertHyperLinkOperation,
@@ -23,17 +23,14 @@ import {
OpenHyperLinkEditPanelOperation,
} from '../commands/operations/popup.operations';
import { InsertLinkShortcut } from '../menu/menu';
import { menuSchema } from '../menu/schema';
export class SheetsHyperLinkUIController extends Disposable {
constructor(
@ICommandService private _commandService: ICommandService,
@IMenuManagerService private readonly _menuManagerService: IMenuManagerService,
@Inject(IShortcutService) private _shortcutService: IShortcutService
@ICommandService private readonly _commandService: ICommandService,
@Inject(IShortcutService) private readonly _shortcutService: IShortcutService
) {
super();
this._initCommands();
this._initMenus();
this._initShortCut();
}
@@ -44,15 +41,11 @@ export class SheetsHyperLinkUIController extends Disposable {
InsertHyperLinkOperation,
InsertHyperLinkToolbarOperation,
].forEach((command) => {
this._commandService.registerCommand(command);
this.disposeWithMe(this._commandService.registerCommand(command));
});
}
private _initMenus() {
this._menuManagerService.mergeMenu(menuSchema);
}
private _initShortCut() {
this._shortcutService.registerShortcut(InsertLinkShortcut);
this.disposeWithMe(this._shortcutService.registerShortcut(InsertLinkShortcut));
}
}
@@ -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 SheetsHyperLinkMenuController extends Disposable {
constructor(@IMenuManagerService menuManagerService: IMenuManagerService) {
super();
menuManagerService.mergeMenu(menuSchema);
}
}
@@ -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 SheetsHyperLinkMobileMenuController extends Disposable {
constructor(@IMenuManagerService menuManagerService: IMenuManagerService) {
super();
menuManagerService.mergeMenu(mobileMenuSchema);
}
}
@@ -0,0 +1,38 @@
/**
* 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 { InsertHyperLinkToolbarOperation } from '../commands/operations/popup.operations';
import { insertLinkMenuFactory, insertLinkMenuToolbarFactory } from './menu';
export const mobileMenuSchema: MenuSchemaType = {
[RibbonInsertGroup.MEDIA]: {
[InsertHyperLinkToolbarOperation.id]: {
order: 1,
menuItemFactory: insertLinkMenuToolbarFactory,
},
},
[ContextMenuPosition.MAIN_AREA]: {
[ContextMenuGroup.OTHERS]: {
order: 1,
[InsertHyperLinkToolbarOperation.id]: {
order: 0,
menuItemFactory: insertLinkMenuFactory,
},
},
},
};
@@ -47,6 +47,7 @@ import { SheetsHyperLinkMobilePopupController } from './controllers/mobile/popup
import { SheetsHyperLinkRenderController } from './controllers/render-controllers/render.controller';
import { SheetsHyperLinkUIController } from './controllers/ui.controller';
import { SheetHyperLinkUrlController } from './controllers/url.controller';
import { SheetsHyperLinkMobileMenuController } from './menu/mobile-hyper-link-menu.controller';
import { SheetsHyperLinkMobilePopupService } from './services/mobile-popup.service';
import { ISheetsHyperLinkPopupService } from './services/popup.service';
import { SheetsHyperLinkResolverService } from './services/resolver.service';
@@ -91,6 +92,7 @@ export class UniverSheetsHyperLinkMobileUIPlugin extends Plugin {
[SheetsHyperLinkSidePanelService],
[SheetsHyperLinkMobilePopupController],
[SheetsHyperLinkUIController],
[SheetsHyperLinkMobileMenuController],
[SheetsHyperLinkAutoFillController],
[SheetsHyperLinkCopyPasteController],
[SheetsHyperLinkPermissionController],
@@ -114,6 +116,7 @@ export class UniverSheetsHyperLinkMobileUIPlugin extends Plugin {
this._injector.get(SheetsHyperLinkAutoFillController);
this._injector.get(SheetsHyperLinkCopyPasteController);
this._injector.get(SheetsHyperLinkUIController);
this._injector.get(SheetsHyperLinkMobileMenuController);
}
override onRendered(): void {
@@ -34,6 +34,7 @@ import { SheetsHyperLinkPopupController } from './controllers/popup.controller';
import { SheetsHyperLinkRenderController } from './controllers/render-controllers/render.controller';
import { SheetsHyperLinkUIController } from './controllers/ui.controller';
import { SheetHyperLinkUrlController } from './controllers/url.controller';
import { SheetsHyperLinkMenuController } from './menu/hyper-link-menu.controller';
import { ISheetsHyperLinkPopupService, SheetsHyperLinkPopupService } from './services/popup.service';
import { SheetsHyperLinkResolverService } from './services/resolver.service';
import { SheetsHyperLinkSidePanelService } from './services/side-panel.service';
@@ -83,6 +84,7 @@ export class UniverSheetsHyperLinkUIPlugin extends Plugin {
[SheetsHyperLinkPopupController],
[SheetsHyperLinkUIController],
[SheetsHyperLinkMenuController],
[SheetsHyperLinkAutoFillController],
[SheetsHyperLinkCopyPasteController],
[SheetsHyperLinkPermissionController],
@@ -99,6 +101,7 @@ export class UniverSheetsHyperLinkUIPlugin extends Plugin {
this._injector.get(SheetsHyperLinkAutoFillController);
this._injector.get(SheetsHyperLinkCopyPasteController);
this._injector.get(SheetsHyperLinkUIController);
this._injector.get(SheetsHyperLinkMenuController);
}
override onRendered(): void {
@@ -16,8 +16,7 @@
import type { LocaleKey } from '../../locale/types';
import { LocaleService } from '@univerjs/core';
import { Button, clsx, Input, MobileActionRow, MobileActionRowGroup, scrollbarClassName } from '@univerjs/design';
import { CheckMarkIcon } from '@univerjs/icons';
import { Button, Input, MobileActionRowGroup, MobileSelect } from '@univerjs/design';
import { useDependency } from '@univerjs/ui';
import { useState } from 'react';
@@ -45,39 +44,22 @@ export function MobileCustomFormat(props: IMobileCustomFormatProps) {
placeholder={title}
onChange={setPattern}
/>
<MobileSelect
className="
univer-w-full univer-shrink-0
[&>div]:univer-font-mono
"
value={pattern || title}
options={patterns.map((item) => ({ label: item, value: item }))}
onChange={setPattern}
/>
<div
data-u-comp="mobile-custom-format-scroller"
className={clsx('univer-min-h-0 univer-flex-1 univer-overflow-y-auto', scrollbarClassName)}
className="
univer-flex-1 univer-px-1 univer-text-sm univer-text-gray-600
dark:!univer-text-gray-200
"
>
<div
className="
univer-overflow-hidden univer-rounded-xl univer-bg-gray-0
dark:!univer-bg-gray-800
"
>
{patterns.map((item, index) => (
<MobileActionRow
key={item}
aria-label={item}
aria-pressed={pattern === item}
title={<span className="univer-break-all univer-font-mono univer-text-sm">{item}</span>}
trailing={pattern === item
? <CheckMarkIcon className="univer-shrink-0 univer-text-primary-600" />
: undefined}
bordered={index !== patterns.length - 1}
className="!univer-rounded-none !univer-font-normal"
onClick={() => setPattern(item)}
/>
))}
</div>
<div
className="
univer-mt-4 univer-px-1 univer-text-sm univer-text-gray-600
dark:!univer-text-gray-200
"
>
{localeService.t<LocaleKey>('sheets-numfmt-ui.customFormatDes')}
</div>
{localeService.t<LocaleKey>('sheets-numfmt-ui.customFormatDes')}
</div>
<MobileActionRowGroup className="univer-shrink-0">
<Button variant="primary" disabled={!pattern.trim()} onClick={() => onConfirm(pattern.trim())}>
@@ -0,0 +1,85 @@
/**
* 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 { Root } from 'react-dom/client';
import { Injector, LocaleService, LocaleType } from '@univerjs/core';
import { ConfigProvider } from '@univerjs/design';
import designEnUS from '@univerjs/design/locale/en-US';
import { RediContext } from '@univerjs/ui';
import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, describe, expect, it, vi } from 'vitest';
import enUS from '../../../locale/en-US';
import { MobileCustomFormat } from '../MobileCustomFormat';
const roots: Root[] = [];
const containers: HTMLElement[] = [];
afterEach(() => {
roots.splice(0).forEach((root) => act(() => root.unmount()));
containers.splice(0).forEach((container) => container.remove());
});
describe('MobileCustomFormat', () => {
it('selects an existing pattern from a mobile dropdown and confirms it', () => {
const injector = new Injector([[LocaleService]]);
const localeService = injector.get(LocaleService);
localeService.load({ [LocaleType.EN_US]: enUS });
localeService.setLocale(LocaleType.EN_US);
const onConfirm = vi.fn();
const container = document.createElement('div');
document.body.appendChild(container);
containers.push(container);
const root = createRoot(container);
roots.push(root);
act(() => {
root.render(
<ConfigProvider locale={designEnUS.design} mountContainer={document.body}>
<RediContext.Provider value={{ injector }}>
<MobileCustomFormat patterns={['0.00', '#,##0']} onConfirm={onConfirm} />
</RediContext.Provider>
</ConfigProvider>
);
});
expect(container.querySelector('[data-u-comp="mobile-custom-format-scroller"]')).toBeNull();
const select = container.querySelector('[data-u-comp="mobile-select"]');
expect(select).not.toBeNull();
act(() => select!.dispatchEvent(new MouseEvent('click', { bubbles: true })));
const dialog = document.body.querySelector('[role="dialog"]');
expect(dialog).not.toBeNull();
expect(Array.from(dialog!.querySelectorAll('button')).some((button) =>
button.textContent?.trim() === 'Custom Format')).toBe(false);
const option = getButton('#,##0');
act(() => option.dispatchEvent(new MouseEvent('click', { bubbles: true })));
expect((container.querySelector('[aria-label="Custom Format"]') as HTMLInputElement).value).toBe('#,##0');
const confirm = getButton('Confirm');
act(() => confirm.dispatchEvent(new MouseEvent('click', { bubbles: true })));
expect(onConfirm).toHaveBeenCalledExactlyOnceWith('#,##0');
});
});
function getButton(name: string): HTMLButtonElement {
const button = Array.from(document.body.querySelectorAll('button')).find((element) =>
element.getAttribute('aria-label') === name || element.textContent?.trim() === name);
if (!button) {
throw new Error(`Button "${name}" was not rendered.`);
}
return button;
}
@@ -0,0 +1,58 @@
/**
* 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 { SheetTableInsertColCommand, SheetTableInsertRowCommand, SheetTableRemoveColCommand, SheetTableRemoveRowCommand } from '@univerjs/sheets-table';
import { ContextMenuGroup, ContextMenuPosition, RibbonDataGroup } from '@univerjs/ui';
import { OpenTableSelectorOperation } from '../commands/operations/open-table-selector.operation';
import { SHEET_TABLE_CONTEXT_INSERT_MENU_ID, SHEET_TABLE_CONTEXT_REMOVE_MENU_ID, SheetTableInsertColMenuFactory, SheetTableInsertContextMenuFactory, SheetTableInsertRowMenuFactory, SheetTableRemoveColMenuFactory, SheetTableRemoveContextMenuFactory, SheetTableRemoveRowMenuFactory, sheetTableToolbarInsertMenuFactory } from './menu';
export const mobileMenuSchema: MenuSchemaType = {
[RibbonDataGroup.ORGANIZATION]: {
[OpenTableSelectorOperation.id]: {
order: 0,
menuItemFactory: sheetTableToolbarInsertMenuFactory,
},
},
[ContextMenuPosition.MAIN_AREA]: {
[ContextMenuGroup.LAYOUT]: {
[SHEET_TABLE_CONTEXT_INSERT_MENU_ID]: {
order: 5,
menuItemFactory: SheetTableInsertContextMenuFactory,
[SheetTableInsertRowCommand.id]: {
order: 1,
menuItemFactory: SheetTableInsertRowMenuFactory,
},
[SheetTableInsertColCommand.id]: {
order: 2,
menuItemFactory: SheetTableInsertColMenuFactory,
},
},
[SHEET_TABLE_CONTEXT_REMOVE_MENU_ID]: {
order: 6,
menuItemFactory: SheetTableRemoveContextMenuFactory,
[SheetTableRemoveRowCommand.id]: {
order: 1,
menuItemFactory: SheetTableRemoveRowMenuFactory,
},
[SheetTableRemoveColCommand.id]: {
order: 2,
menuItemFactory: SheetTableRemoveColMenuFactory,
},
},
},
},
};
@@ -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 MobileSheetTableMenuController extends Disposable {
constructor(@IMenuManagerService menuManagerService: IMenuManagerService) {
super();
menuManagerService.mergeMenu(mobileMenuSchema);
}
}
@@ -45,7 +45,7 @@ import { SheetsTableFilterButtonRenderController } from './controllers/sheet-tab
import { SheetsTableRenderController } from './controllers/sheet-table-render.controller';
import { SheetTableSelectionController } from './controllers/sheet-table-selection.controller';
import { SheetTableThemeUIController } from './controllers/sheet-table-theme-ui.controller';
import { SheetTableMenuController } from './menu/sheet-table-menu.controller';
import { MobileSheetTableMenuController } from './menu/mobile-sheet-table-menu.controller';
import { SheetsTableUiService } from './services/sheets-table-ui.service';
@DependentOn(
@@ -86,7 +86,7 @@ export class UniverSheetsTableMobileUIPlugin extends Plugin {
registerDependencies(this._injector, [
[SheetsTableMobileComponentController],
[SheetsTableUiService],
[SheetTableMenuController],
[MobileSheetTableMenuController],
[SheetTableThemeUIController],
[SheetTableSelectionController],
]);
@@ -96,7 +96,7 @@ export class UniverSheetsTableMobileUIPlugin extends Plugin {
touchDependencies(this._injector, [
[SheetsTableMobileComponentController],
[SheetsTableUiService],
[SheetTableMenuController],
[MobileSheetTableMenuController],
[SheetTableThemeUIController],
[SheetTableSelectionController],
]);
@@ -116,7 +116,7 @@ import {
MOBILE_FX_EDITOR_EXPANDED,
MOBILE_KEYBOARD_VISIBLE,
} from '../../consts/mobile-context';
import { menuSchema } from '../../menu/schema';
import { mobileMenuSchema } from '../../menu/mobile-schema';
import { IEditorBridgeService } from '../../services/editor-bridge.service';
import { MobileSheetActionPanel } from '../../views/mobile/action-panel/MobileSheetActionPanel';
import { MobileFormulaBar } from '../../views/mobile/formula-bar/MobileFormulaBar';
@@ -307,7 +307,7 @@ export class SheetUIMobileController extends Disposable {
}
private _initMenus(): void {
this._menuManagerService.mergeMenu(menuSchema);
this._menuManagerService.mergeMenu(mobileMenuSchema);
}
private _initWorkbenchParts(): void {
@@ -323,11 +323,18 @@ export class SheetUIMobileController extends Disposable {
private _initFocusHandler(): void {
this.disposeWithMe(
this._layoutService.registerFocusHandler(UniverInstanceType.UNIVER_SHEET, (_unitId: string) => {
if (this._editorBridgeService.isVisible().visible) this._focusCellEditorInput();
if (
this._editorBridgeService.isVisible().visible &&
!this._contextService.getContextValue(MOBILE_FORMULA_FUNCTION_PANEL_OPEN)
) {
this._focusCellEditorInput();
}
})
);
this.disposeWithMe(this._editorBridgeService.visible$.subscribe(({ visible }) => {
if (visible) this._focusCellEditorInput();
if (visible && !this._contextService.getContextValue(MOBILE_FORMULA_FUNCTION_PANEL_OPEN)) {
this._focusCellEditorInput();
}
}));
}
@@ -29,6 +29,7 @@ import { SetActivateCellEditOperation } from '../../../commands/operations/activ
import { SetCellEditVisibleOperation } from '../../../commands/operations/cell-edit.operation';
import { SHEET_VIEW_KEY } from '../../../common/keys';
import { EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE } from '../../../services/sheet-embed-integration.service';
import { DesktopCellEditRenderController } from '../desktop-cell-edit.render-controller';
import { EditorBridgeRenderController } from '../editor-bridge.render-controller';
function createEventSubject() {
@@ -146,68 +147,89 @@ function createController(options?: {
refreshEditCellState: vi.fn(),
};
commandService.syncExecuteCommand.mockReturnValue(true);
const controller = new EditorBridgeRenderController(
context as any,
{
getCurrentTypeOfUnit$: vi.fn(() => workbook$),
getFocusedUnit: vi.fn(() => ({
getUnitId: () => options?.focusedUnitId ?? 'unit-1',
})),
getUnit: vi.fn(() => ({
getBody: () => ({ dataStream: '=\r\n' }),
})),
} as any,
commandService as any,
editorBridgeService as any,
{
const instanceService = {
getCurrentTypeOfUnit$: vi.fn(() => workbook$),
getFocusedUnit: vi.fn(() => ({
getUnitId: () => options?.focusedUnitId ?? 'unit-1',
})),
getUnit: vi.fn(() => ({
getBody: () => ({ dataStream: '=\r\n' }),
})),
};
const selectionManagerService = {
selectionMoveEnd$,
selectionMoveStart$,
selectionSet$,
getWorkbookSelections: vi.fn(() => ({
selectionMoveEnd$,
selectionMoveStart$,
selectionSet$,
getWorkbookSelections: vi.fn(() => ({
selectionMoveEnd$,
selectionMoveStart$,
selectionSet$,
getCurrentSelections: vi.fn(() => [{
primary: {
actualRow: 3,
actualColumn: 4,
startRow: 3,
startColumn: 4,
endRow: 3,
endColumn: 4,
},
}]),
})),
} as any,
{
getContextValue: vi.fn((key: string) => contextValues.get(key)),
setContextValue: vi.fn((key: string, value: unknown) => contextValues.set(key, value)),
subscribeContextValue$: vi.fn((key: string) => key === FOCUSING_SHEET ? focusingSheet$ : new Subject<unknown>()),
} as any,
renderManagerService as any,
{
getSkeletonParam: vi.fn(() => ({
skeleton: {
getCellWithCoordByIndex: vi.fn(() => ({
actualRow: 1,
actualColumn: 2,
isMerged: true,
isMergedMainCell: true,
mergeInfo: {
startRow: 1,
startColumn: 2,
endRow: 4,
endColumn: 5,
},
})),
getCurrentSelections: vi.fn(() => [{
primary: {
actualRow: 3,
actualColumn: 4,
startRow: 3,
startColumn: 4,
endRow: 3,
endColumn: 4,
},
})),
} as any,
{
isChildUnitRuntimeEvent: vi.fn((unitId, target, event) => options?.isEmbedRuntimeEventImpl?.(unitId, target, event) ?? options?.isEmbedRuntimeEvent ?? false),
isChildUnitInActiveSession: vi.fn(() => options?.isEmbedActiveSession ?? false),
} as unknown as EmbedRuntimeFocusCoordinator
}]),
})),
};
const contextService = {
getContextValue: vi.fn((key: string) => contextValues.get(key)),
setContextValue: vi.fn((key: string, value: unknown) => contextValues.set(key, value)),
subscribeContextValue$: vi.fn((key: string) => key === FOCUSING_SHEET ? focusingSheet$ : new Subject<unknown>()),
};
const embedRuntimeFocusCoordinator = {
isChildUnitRuntimeEvent: vi.fn((unitId, target, event) =>
options?.isEmbedRuntimeEventImpl?.(unitId, target, event) ?? options?.isEmbedRuntimeEvent ?? false),
isChildUnitInActiveSession: vi.fn(() => options?.isEmbedActiveSession ?? false),
} as unknown as EmbedRuntimeFocusCoordinator;
const sheetSkeletonManagerService = {
getSkeletonParam: vi.fn(() => ({
skeleton: {
getCellWithCoordByIndex: vi.fn(() => ({
actualRow: 1,
actualColumn: 2,
isMerged: true,
isMergedMainCell: true,
mergeInfo: {
startRow: 1,
startColumn: 2,
endRow: 4,
endColumn: 5,
},
})),
},
})),
};
const editorBridgeRenderController = new EditorBridgeRenderController(
context as never,
instanceService as never,
commandService as never,
editorBridgeService as never,
selectionManagerService as never,
sheetSkeletonManagerService as never,
embedRuntimeFocusCoordinator
);
const desktopCellEditRenderController = new DesktopCellEditRenderController(
context as never,
instanceService as never,
commandService as never,
editorBridgeService as never,
selectionManagerService as never,
contextService as never,
renderManagerService as never,
embedRuntimeFocusCoordinator
);
const controller = {
dispose: () => {
desktopCellEditRenderController.dispose();
editorBridgeRenderController.dispose();
},
refreshEditorPosition: () => editorBridgeRenderController.refreshEditorPosition(),
};
workbook$.next(workbook);
return {
@@ -327,11 +349,16 @@ describe('EditorBridgeRenderController business flows', () => {
});
it('requires an explicit edit action when mobile auto focus is disabled', () => {
const { commandService, controller, inputBefore$, spreadsheet } = createController({ disableAutoFocus: true });
const { commandService, controller, docSelectionRenderService, inputBefore$, spreadsheet } = createController({
disableAutoFocus: true,
});
inputBefore$.next({ event: { data: 'A', which: 65 } });
expect(commandService.syncExecuteCommand).not.toHaveBeenCalledWith(SetCellEditVisibleOperation.id, expect.anything());
spreadsheet.onPointerDown$.emit({});
expect(docSelectionRenderService.focus).not.toHaveBeenCalled();
spreadsheet.onDblclick$.emit({ button: 0 });
expect(commandService.executeCommand).toHaveBeenCalledWith(SetCellEditVisibleOperation.id, {
visible: true,
@@ -0,0 +1,403 @@
/**
* 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 { IDisposable, Nullable, Workbook } from '@univerjs/core';
import type { IEditorInputConfig } from '@univerjs/docs-ui';
import type { IRender, IRenderContext, IRenderModule } from '@univerjs/engine-render';
import type { IEditorBridgeServiceVisibleParam } from '../../services/editor-bridge.service';
import type { ISheetObjectParam } from '../utils/component-tools';
import {
DisposableCollection,
DOCS_NORMAL_EDITOR_UNIT_ID_KEY,
FOCUSING_FX_BAR_EDITOR,
FOCUSING_SHEET,
ICommandService,
IContextService,
Inject,
IUniverInstanceService,
Optional,
RxDisposable,
toDisposable,
UniverInstanceType,
} from '@univerjs/core';
import { DocSelectionRenderService } from '@univerjs/docs-ui';
import { DeviceInputEventType, IRenderManagerService } from '@univerjs/engine-render';
import { isCellImage, SheetsSelectionsService } from '@univerjs/sheets';
import { DISABLE_AUTO_FOCUS_KEY, getEmbedChildUnitId } from '@univerjs/ui';
import { filter } from 'rxjs';
import { SetCellEditVisibleOperation } from '../../commands/operations/cell-edit.operation';
import { IEditorBridgeService } from '../../services/editor-bridge.service';
import {
ISheetEmbedRuntimeFocusCoordinator,
SHEET_EMBED_RUNTIME_FOCUS_ROLE_ATTRIBUTE,
} from '../../services/sheet-embed-integration.service';
import { getSheetObject } from '../utils/component-tools';
export class DesktopCellEditRenderController extends RxDisposable implements IRenderModule {
private _d: Nullable<IDisposable>;
constructor(
private readonly _context: IRenderContext<Workbook>,
@IUniverInstanceService private readonly _instanceService: IUniverInstanceService,
@ICommandService private readonly _commandService: ICommandService,
@IEditorBridgeService private readonly _editorBridgeService: IEditorBridgeService,
@Inject(SheetsSelectionsService) private readonly _selectionManagerService: SheetsSelectionsService,
@IContextService private readonly _contextService: IContextService,
@IRenderManagerService private readonly _renderManagerService: IRenderManagerService,
@Optional(ISheetEmbedRuntimeFocusCoordinator)
private readonly _embedRuntimeFocusCoordinator?: ISheetEmbedRuntimeFocusCoordinator
) {
super();
this.disposeWithMe(
this._instanceService.getCurrentTypeOfUnit$(UniverInstanceType.UNIVER_SHEET).subscribe((workbook) => {
if (workbook && workbook.getUnitId() === this._context.unitId) {
if (!this._d) {
this._d = this._init();
}
} else {
this._disposeCurrent();
}
})
);
}
private _init(): IDisposable {
const d = new DisposableCollection();
this._initEventListener(d);
this._initialKeyboardListener(d);
this._initSheetFocusListener(d);
this._initInputPositionListener(d);
return d;
}
private _disposeCurrent(): void {
this._d?.dispose();
this._d = null;
}
private _initEventListener(d: DisposableCollection): void {
const sheetObject = this._getSheetObject();
if (!sheetObject) {
return;
}
const { spreadsheet, spreadsheetColumnHeader, spreadsheetLeftTopPlaceholder, spreadsheetRowHeader } = sheetObject;
d.add(spreadsheet.onDblclick$.subscribeEvent((evt) => {
if (evt.button === 2) {
return;
}
const snapshot = this._editorBridgeService.getEditCellState()?.documentLayoutObject.documentModel?.getSnapshot();
if (isCellImage(snapshot)) {
return;
}
this._commandService.executeCommand(SetCellEditVisibleOperation.id, {
visible: true,
eventType: DeviceInputEventType.Dblclick,
unitId: this._context.unitId,
} as IEditorBridgeServiceVisibleParam);
}));
d.add(spreadsheet.onPointerDown$.subscribeEvent({
next: (payload) => {
this._tryHideEditor(resolvePointerEventPayload(payload));
this._focusCellEditorInput();
},
priority: -1,
}));
d.add(spreadsheetColumnHeader.onPointerDown$.subscribeEvent({
next: (payload) => this._tryHideEditor(resolvePointerEventPayload(payload)),
priority: -1,
}));
d.add(spreadsheetLeftTopPlaceholder.onPointerDown$.subscribeEvent({
next: (payload) => this._tryHideEditor(resolvePointerEventPayload(payload)),
priority: -1,
}));
d.add(spreadsheetRowHeader.onPointerDown$.subscribeEvent({
next: (payload) => this._tryHideEditor(resolvePointerEventPayload(payload)),
priority: -1,
}));
}
private _initialKeyboardListener(d: DisposableCollection): void {
let disposable: Nullable<IDisposable> = null;
const addEvent = (render: IRender) => {
disposable?.dispose();
disposable = null;
const docSelectionRenderService = render.with(DocSelectionRenderService);
if (docSelectionRenderService) {
disposable = toDisposable(docSelectionRenderService.onInputBefore$.subscribe((config) => {
if (this._contextService.getContextValue(DISABLE_AUTO_FOCUS_KEY)) {
return;
}
if (!this._isCurrentSheetFocused()) {
return;
}
const isFocusFormulaEditor = this._contextService.getContextValue(FOCUSING_FX_BAR_EDITOR);
const isFocusSheets = this._contextService.getContextValue(FOCUSING_SHEET) ||
this._embedRuntimeFocusCoordinator?.isChildUnitInActiveSession(this._context.unitId) === true;
if (
render.unitId &&
isFocusSheets &&
!isFocusFormulaEditor &&
!this._editorBridgeService.isVisible().visible
) {
this._showEditorByKeyboard(config);
}
}));
}
};
const render = this._renderManagerService.getRenderUnitById(DOCS_NORMAL_EDITOR_UNIT_ID_KEY);
if (render) {
addEvent(render);
}
d.add(
this._renderManagerService.created$
.pipe(filter((renderUnit) => renderUnit.unitId === DOCS_NORMAL_EDITOR_UNIT_ID_KEY))
.subscribe(addEvent)
);
d.add(toDisposable(() => {
disposable?.dispose();
disposable = null;
}));
}
private _initSheetFocusListener(d: DisposableCollection): void {
d.add(this._contextService.subscribeContextValue$(FOCUSING_SHEET).subscribe((isFocusingSheet) => {
if (
!isFocusingSheet ||
!this._isCurrentSheetFocused() ||
this._contextService.getContextValue(FOCUSING_FX_BAR_EDITOR) ||
this._editorBridgeService.isVisible().visible
) {
return;
}
this._focusCellEditorInput();
}));
}
private _initInputPositionListener(d: DisposableCollection): void {
const selections = this._selectionManagerService.getWorkbookSelections(this._context.unitId);
d.add(selections.selectionMoveEnd$.subscribe((params) => {
if (params?.[params.length - 1]?.primary) {
this._updateInputPosition();
}
}));
}
private _updateInputPosition(): void {
if (this._editorBridgeService.isVisible().visible) {
return;
}
const layout = this._editorBridgeService.getEditCellLayout();
const docSelectionRenderService = this._renderManagerService
.getRenderUnitById(DOCS_NORMAL_EDITOR_UNIT_ID_KEY)
?.with(DocSelectionRenderService);
if (!layout || !docSelectionRenderService) {
return;
}
const { position, canvasOffset } = layout;
docSelectionRenderService.setInputPosition(
canvasOffset.left + position.startX,
canvasOffset.top + position.startY
);
}
private _focusCellEditorInput(): void {
const focusedChildUnitId = getEmbedChildUnitId(typeof document === 'undefined' ? null : document.activeElement);
if (
this._contextService.getContextValue(DISABLE_AUTO_FOCUS_KEY) ||
(focusedChildUnitId != null && focusedChildUnitId !== this._context.unitId) ||
!this._isCurrentSheetFocused() ||
this._contextService.getContextValue(FOCUSING_FX_BAR_EDITOR) ||
this._editorBridgeService.isVisible().visible
) {
return;
}
const render = this._renderManagerService.getRenderUnitById(DOCS_NORMAL_EDITOR_UNIT_ID_KEY);
const docSelectionRenderService = render?.with(DocSelectionRenderService);
if (!docSelectionRenderService?.isFocusing) {
docSelectionRenderService?.focus();
}
}
private _showEditorByKeyboard(config: Nullable<IEditorInputConfig>): void {
const event = config?.event as InputEvent;
if (config == null || (!event.data && event.inputType !== 'InsertParagraph')) {
return;
}
const initialValue = config.content ?? event.data ?? '';
this._commandService.syncExecuteCommand(SetCellEditVisibleOperation.id, {
visible: true,
eventType: DeviceInputEventType.Keyboard,
keycode: event.which,
initialValue,
unitId: this._context.unitId,
});
}
private _tryHideEditor(evt?: PointerEventLike): void {
if (this._editorBridgeService.isForceKeepVisible()) {
return;
}
if (!evt && this._isEmbeddedFormulaEditorActive()) {
return;
}
if (this._isEmbeddedFormulaEditorActive() && this._isCurrentEmbedRuntimeEvent(evt)) {
return;
}
if (isEmbedCellEditorInteraction(evt)) {
return;
}
this._hideEditor();
}
private _isEmbeddedFormulaEditorActive(): boolean {
if (this._embedRuntimeFocusCoordinator?.isChildUnitInActiveSession(this._context.unitId) !== true) {
return false;
}
const editCellState = this._editorBridgeService.getEditCellState();
const dataStream = editCellState?.documentLayoutObject.documentModel?.getSnapshot().body?.dataStream;
return typeof dataStream === 'string' && dataStream.startsWith('=');
}
private _isCurrentEmbedRuntimeEvent(evt?: PointerEventLike): boolean {
return this._embedRuntimeFocusCoordinator?.isChildUnitRuntimeEvent(
this._context.unitId,
evt?.target,
evt instanceof Event ? evt : evt as Event | undefined
) === true;
}
private _hideEditor(): void {
if (this._editorBridgeService.isVisible().visible !== true) {
return;
}
this._commandService.syncExecuteCommand(SetCellEditVisibleOperation.id, {
visible: false,
eventType: DeviceInputEventType.PointerDown,
unitId: this._context.unitId,
});
}
private _getSheetObject(): Nullable<ISheetObjectParam> {
if (!this._context.unit) {
return null;
}
return getSheetObject(this._context.unit, this._context);
}
private _isCurrentSheetFocused(): boolean {
return this._instanceService.getFocusedUnit()?.getUnitId() === this._context.unitId ||
this._embedRuntimeFocusCoordinator?.isChildUnitInActiveSession(this._context.unitId) === true;
}
}
type PointerEventLike = Event | {
target?: EventTarget | null;
clientX?: number;
clientY?: number;
x?: number;
y?: number;
};
interface IPointerEventPoint {
clientX?: number;
clientY?: number;
x?: number;
y?: number;
}
function resolvePointerEventPayload(
payload: PointerEventLike | [PointerEventLike, unknown] | undefined
): PointerEventLike | undefined {
return Array.isArray(payload) ? payload[0] : payload;
}
function isEmbedCellEditorInteraction(evt: PointerEventLike | undefined): boolean {
return isEmbedCellEditorInteractionTarget(evt?.target) || isEmbedCellEditorInteractionPoint(resolvePointerEventPoint(evt));
}
function isEmbedCellEditorInteractionTarget(target: EventTarget | null | undefined): boolean {
if (typeof HTMLElement === 'undefined' || !(target instanceof HTMLElement)) {
return false;
}
return target.closest(`[${SHEET_EMBED_RUNTIME_FOCUS_ROLE_ATTRIBUTE}="child-editor"]`) != null ||
target.closest('[data-u-comp="editor"]') != null ||
target.closest('[id^="__editor___INTERNAL_EDITOR__"]') != null ||
target.closest('[id^="univer-doc-selection-container-__INTERNAL_EDITOR__"]') != null;
}
function resolvePointerEventPoint(evt: PointerEventLike | undefined): IPointerEventPoint | undefined {
if (!evt) {
return undefined;
}
return {
clientX: 'clientX' in evt ? evt.clientX : undefined,
clientY: 'clientY' in evt ? evt.clientY : undefined,
x: 'x' in evt ? evt.x : undefined,
y: 'y' in evt ? evt.y : undefined,
};
}
function isEmbedCellEditorInteractionPoint(evt: IPointerEventPoint | undefined): boolean {
if (typeof document === 'undefined') {
return false;
}
const clientX = Number.isFinite(evt?.clientX) ? evt?.clientX : evt?.x;
const clientY = Number.isFinite(evt?.clientY) ? evt?.clientY : evt?.y;
if (!Number.isFinite(clientX) || !Number.isFinite(clientY)) {
return false;
}
const editorRoots = document.querySelectorAll<HTMLElement>([
`[${SHEET_EMBED_RUNTIME_FOCUS_ROLE_ATTRIBUTE}="child-editor"]`,
'[data-u-comp="editor"]',
'[id^="__editor___INTERNAL_EDITOR__"]',
'[id^="univer-doc-selection-container-__INTERNAL_EDITOR__"]',
].join(','));
return [...editorRoots].some((element) => {
const rect = element.getBoundingClientRect();
return rect.width > 0 &&
rect.height > 0 &&
clientX! >= rect.left &&
clientX! <= rect.right &&
clientY! >= rect.top &&
clientY! <= rect.bottom;
});
}
@@ -15,27 +15,23 @@
*/
import type { ICommandInfo, IDisposable, IExecutionOptions, ISelectionCell, Nullable, Workbook } from '@univerjs/core';
import type { IEditorInputConfig } from '@univerjs/docs-ui';
import type { IRender, IRenderContext, IRenderModule } from '@univerjs/engine-render';
import type { IRenderContext, IRenderModule } from '@univerjs/engine-render';
import type { ISelectionWithStyle } from '@univerjs/sheets';
import type { ICurrentEditCellParam, IEditorBridgeServiceVisibleParam } from '../../services/editor-bridge.service';
import type { ICurrentEditCellParam } from '../../services/editor-bridge.service';
import type { ISheetObjectParam } from '../utils/component-tools';
import { DisposableCollection, DOCS_NORMAL_EDITOR_UNIT_ID_KEY, FOCUSING_FX_BAR_EDITOR, FOCUSING_SHEET, ICommandService, IContextService, Inject, IUniverInstanceService, Optional, RxDisposable, toDisposable, UniverInstanceType } from '@univerjs/core';
import { DocSelectionRenderService } from '@univerjs/docs-ui';
import { DeviceInputEventType, IRenderManagerService } from '@univerjs/engine-render';
import { DisposableCollection, ICommandService, Inject, IUniverInstanceService, Optional, RxDisposable, UniverInstanceType } from '@univerjs/core';
import { DeviceInputEventType } from '@univerjs/engine-render';
import {
ClearSelectionFormatCommand,
isCellImage,
SetWorksheetActiveOperation,
SheetsSelectionsService,
} from '@univerjs/sheets';
import { DISABLE_AUTO_FOCUS_KEY, getEmbedChildUnitId } from '@univerjs/ui';
import { filter, merge } from 'rxjs';
import { merge } from 'rxjs';
import { SetZoomRatioCommand } from '../../commands/commands/set-zoom-ratio.command';
import { SetActivateCellEditOperation } from '../../commands/operations/activate-cell-edit.operation';
import { SetCellEditVisibleOperation } from '../../commands/operations/cell-edit.operation';
import { IEditorBridgeService } from '../../services/editor-bridge.service';
import { ISheetEmbedRuntimeFocusCoordinator, SHEET_EMBED_RUNTIME_FOCUS_ROLE_ATTRIBUTE } from '../../services/sheet-embed-integration.service';
import { ISheetEmbedRuntimeFocusCoordinator } from '../../services/sheet-embed-integration.service';
import { SheetSkeletonManagerService } from '../../services/sheet-skeleton-manager.service';
import { getSheetObject } from '../utils/component-tools';
@@ -50,8 +46,6 @@ export class EditorBridgeRenderController extends RxDisposable implements IRende
@ICommandService private readonly _commandService: ICommandService,
@IEditorBridgeService private readonly _editorBridgeService: IEditorBridgeService,
@Inject(SheetsSelectionsService) private readonly _selectionManagerService: SheetsSelectionsService,
@IContextService private readonly _contextService: IContextService,
@IRenderManagerService private readonly _renderManagerService: IRenderManagerService,
@Inject(SheetSkeletonManagerService) private readonly _sheetSkeletonManagerService: SheetSkeletonManagerService,
@Optional(ISheetEmbedRuntimeFocusCoordinator) private readonly _embedRuntimeFocusCoordinator?: ISheetEmbedRuntimeFocusCoordinator
) {
@@ -72,10 +66,7 @@ export class EditorBridgeRenderController extends RxDisposable implements IRende
private _init(): IDisposable {
const d = new DisposableCollection();
this._initSelectionChangeListener(d);
this._initEventListener(d);
this._commandExecutedListener(d);
this._initialKeyboardListener(d);
this._initSheetFocusListener(d);
return d;
}
@@ -92,9 +83,6 @@ export class EditorBridgeRenderController extends RxDisposable implements IRende
).subscribe((params) => this._updateEditorPosition(params)));
d.add(selections.selectionMoveEnd$.subscribe((params) => {
this._updateEditorPosition(params);
if (params?.[params.length - 1]?.primary) {
this._updateInputPosition();
}
}));
}
@@ -137,26 +125,6 @@ export class EditorBridgeRenderController extends RxDisposable implements IRende
}
}
private _updateInputPosition() {
if (this._editorBridgeService.isVisible().visible) {
return;
}
const layout = this._editorBridgeService.getEditCellLayout();
const docSelectionRenderService = this._renderManagerService
.getRenderUnitById(DOCS_NORMAL_EDITOR_UNIT_ID_KEY)
?.with(DocSelectionRenderService);
if (!layout || !docSelectionRenderService) {
return;
}
const { position, canvasOffset } = layout;
docSelectionRenderService.setInputPosition(
canvasOffset.left + position.startX,
canvasOffset.top + position.startY
);
}
refreshEditorPosition() {
const workbookSelections = this._selectionManagerService.getWorkbookSelections(this._context.unitId);
if (workbookSelections) {
@@ -167,130 +135,6 @@ export class EditorBridgeRenderController extends RxDisposable implements IRende
}
}
private _initEventListener(d: DisposableCollection) {
const sheetObject = this._getSheetObject();
if (!sheetObject) {
return;
}
const { spreadsheet, spreadsheetColumnHeader, spreadsheetLeftTopPlaceholder, spreadsheetRowHeader } = sheetObject;
d.add(spreadsheet.onDblclick$.subscribeEvent((evt) => {
if (evt.button === 2) {
return;
}
const snapshot = this._editorBridgeService.getEditCellState()?.documentLayoutObject.documentModel?.getSnapshot();
if (isCellImage(snapshot)) {
return;
}
this._commandService.executeCommand(SetCellEditVisibleOperation.id, {
visible: true,
eventType: DeviceInputEventType.Dblclick,
unitId: this._context.unitId,
} as IEditorBridgeServiceVisibleParam);
}));
d.add(spreadsheet.onPointerDown$.subscribeEvent({
next: (payload) => {
this._tryHideEditor(resolvePointerEventPayload(payload));
this._focusCellEditorInput();
},
priority: -1,
}));
d.add(spreadsheetColumnHeader.onPointerDown$.subscribeEvent({
next: (payload) => this._tryHideEditor(resolvePointerEventPayload(payload)),
priority: -1,
}));
d.add(spreadsheetLeftTopPlaceholder.onPointerDown$.subscribeEvent({
next: (payload) => this._tryHideEditor(resolvePointerEventPayload(payload)),
priority: -1,
}));
d.add(spreadsheetRowHeader.onPointerDown$.subscribeEvent({
next: (payload) => this._tryHideEditor(resolvePointerEventPayload(payload)),
priority: -1,
}));
}
/**
* Should activate the editor when the user inputs text.
* @param d DisposableCollection
*/
private _initialKeyboardListener(d: DisposableCollection) {
let disposable: Nullable<IDisposable> = null;
const addEvent = (render: IRender) => {
disposable?.dispose();
disposable = null;
const docSelectionRenderService = render.with(DocSelectionRenderService);
if (docSelectionRenderService) {
disposable = toDisposable(docSelectionRenderService.onInputBefore$.subscribe((config) => {
if (this._contextService.getContextValue(DISABLE_AUTO_FOCUS_KEY)) {
return;
}
if (!this._isCurrentSheetFocused()) {
return;
}
const isFocusFormulaEditor = this._contextService.getContextValue(FOCUSING_FX_BAR_EDITOR);
const isFocusSheets = this._contextService.getContextValue(FOCUSING_SHEET) ||
this._embedRuntimeFocusCoordinator?.isChildUnitInActiveSession(this._context.unitId) === true;
const unitId = render.unitId;
if (this._editorBridgeService.isVisible().visible) return;
if (unitId && isFocusSheets && !isFocusFormulaEditor) {
this._showEditorByKeyboard(config);
}
}));
}
};
const render = this._renderManagerService.getRenderUnitById(DOCS_NORMAL_EDITOR_UNIT_ID_KEY);
if (render) {
addEvent(render);
}
d.add(this._renderManagerService.created$.pipe(filter((render) => render.unitId === DOCS_NORMAL_EDITOR_UNIT_ID_KEY)).subscribe(addEvent));
d.add(toDisposable(() => {
disposable?.dispose();
disposable = null;
}));
}
private _initSheetFocusListener(d: DisposableCollection) {
d.add(this._contextService.subscribeContextValue$(FOCUSING_SHEET).subscribe((isFocusingSheet) => {
if (
this._contextService.getContextValue(DISABLE_AUTO_FOCUS_KEY) ||
!isFocusingSheet ||
!this._isCurrentSheetFocused() ||
this._contextService.getContextValue(FOCUSING_FX_BAR_EDITOR) ||
this._editorBridgeService.isVisible().visible
) {
return;
}
this._focusCellEditorInput();
}));
}
private _focusCellEditorInput(): void {
// Restoring the host context after a child command must not reclaim the child's keyboard focus.
const focusedChildUnitId = getEmbedChildUnitId(typeof document === 'undefined' ? null : document.activeElement);
if (
(focusedChildUnitId != null && focusedChildUnitId !== this._context.unitId) ||
!this._isCurrentSheetFocused() ||
this._contextService.getContextValue(FOCUSING_FX_BAR_EDITOR) ||
this._editorBridgeService.isVisible().visible
) {
return;
}
const render = this._renderManagerService.getRenderUnitById(DOCS_NORMAL_EDITOR_UNIT_ID_KEY);
const docSelectionRenderService = render?.with(DocSelectionRenderService);
if (!docSelectionRenderService?.isFocusing) {
docSelectionRenderService?.focus();
}
}
private _commandExecutedListener(d: DisposableCollection) {
const refreshCommandSet = new Set([ClearSelectionFormatCommand.id, SetZoomRatioCommand.id]);
d.add(this._commandService.onCommandExecuted((command: ICommandInfo) => {
@@ -321,35 +165,13 @@ export class EditorBridgeRenderController extends RxDisposable implements IRende
}));
}
private _showEditorByKeyboard(config: Nullable<IEditorInputConfig>) {
const event = config?.event as InputEvent;
if (config == null || (!event.data && event.inputType !== 'InsertParagraph')) {
return;
}
const initialValue = config.content ?? event.data ?? '';
this._commandService.syncExecuteCommand(SetCellEditVisibleOperation.id, {
visible: true,
eventType: DeviceInputEventType.Keyboard,
keycode: event.which,
initialValue,
unitId: this._context.unitId,
});
}
private _tryHideEditor(evt?: Event | { target?: EventTarget | null; clientX?: number; clientY?: number; x?: number; y?: number }) {
private _tryHideEditor() {
// In the activated state of formula editing,
// prohibit closing the editor according to the state to facilitate generating selection reference text.
if (this._editorBridgeService.isForceKeepVisible()) {
return;
}
if (!evt && this._isEmbeddedFormulaEditorActive()) {
return;
}
if (this._isEmbeddedFormulaEditorActive() && this._isCurrentEmbedRuntimeEvent(evt)) {
return;
}
if (isEmbedCellEditorInteraction(evt)) {
if (this._isEmbeddedFormulaEditorActive()) {
return;
}
@@ -366,14 +188,6 @@ export class EditorBridgeRenderController extends RxDisposable implements IRende
return typeof dataStream === 'string' && dataStream.startsWith('=');
}
private _isCurrentEmbedRuntimeEvent(evt?: PointerEventLike): boolean {
return this._embedRuntimeFocusCoordinator?.isChildUnitRuntimeEvent(
this._context.unitId,
evt?.target,
evt instanceof Event ? evt : evt as Event | undefined
) === true;
}
private _hideEditor() {
if (this._editorBridgeService.isVisible().visible !== true) return;
@@ -391,74 +205,6 @@ export class EditorBridgeRenderController extends RxDisposable implements IRende
return getSheetObject(this._context.unit, this._context);
}
private _isCurrentSheetFocused(): boolean {
return this._instanceSrv.getFocusedUnit()?.getUnitId() === this._context.unitId ||
this._embedRuntimeFocusCoordinator?.isChildUnitInActiveSession(this._context.unitId) === true;
}
}
type PointerEventLike = Event | { target?: EventTarget | null; clientX?: number; clientY?: number; x?: number; y?: number };
function resolvePointerEventPayload(payload: PointerEventLike | [PointerEventLike, unknown] | undefined): PointerEventLike | undefined {
return Array.isArray(payload) ? payload[0] : payload;
}
function isEmbedCellEditorInteraction(evt: PointerEventLike | undefined): boolean {
return isEmbedCellEditorInteractionTarget(evt?.target) || isEmbedCellEditorInteractionPoint(resolvePointerEventPoint(evt));
}
function isEmbedCellEditorInteractionTarget(target: EventTarget | null | undefined): boolean {
if (typeof HTMLElement === 'undefined' || !(target instanceof HTMLElement)) {
return false;
}
return target.closest(`[${SHEET_EMBED_RUNTIME_FOCUS_ROLE_ATTRIBUTE}="child-editor"]`) != null ||
target.closest('[data-u-comp="editor"]') != null ||
target.closest('[id^="__editor___INTERNAL_EDITOR__"]') != null ||
target.closest('[id^="univer-doc-selection-container-__INTERNAL_EDITOR__"]') != null;
}
function resolvePointerEventPoint(evt: PointerEventLike | undefined): { clientX?: number; clientY?: number; x?: number; y?: number } | undefined {
if (!evt) {
return undefined;
}
return {
clientX: 'clientX' in evt ? evt.clientX : undefined,
clientY: 'clientY' in evt ? evt.clientY : undefined,
x: 'x' in evt ? evt.x : undefined,
y: 'y' in evt ? evt.y : undefined,
};
}
function isEmbedCellEditorInteractionPoint(evt: { clientX?: number; clientY?: number; x?: number; y?: number } | undefined): boolean {
if (typeof document === 'undefined') {
return false;
}
const clientX = Number.isFinite(evt?.clientX) ? evt?.clientX : evt?.x;
const clientY = Number.isFinite(evt?.clientY) ? evt?.clientY : evt?.y;
if (!Number.isFinite(clientX) || !Number.isFinite(clientY)) {
return false;
}
const editorRoots = document.querySelectorAll<HTMLElement>([
`[${SHEET_EMBED_RUNTIME_FOCUS_ROLE_ATTRIBUTE}="child-editor"]`,
'[data-u-comp="editor"]',
'[id^="__editor___INTERNAL_EDITOR__"]',
'[id^="univer-doc-selection-container-__INTERNAL_EDITOR__"]',
].join(','));
return [...editorRoots].some((element) => {
const rect = element.getBoundingClientRect();
return rect.width > 0 &&
rect.height > 0 &&
clientX! >= rect.left &&
clientX! <= rect.right &&
clientY! >= rect.top &&
clientY! <= rect.bottom;
});
}
function isSameEditCell(
@@ -0,0 +1,122 @@
/**
* 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 { EventSubject } from '@univerjs/core';
import { DeviceInputEventType } from '@univerjs/engine-render';
import { Subject } from 'rxjs';
import { describe, expect, it, vi } from 'vitest';
import { SetCellEditVisibleOperation } from '../../../../commands/operations/cell-edit.operation';
import { SHEET_VIEW_KEY } from '../../../../common/keys';
import { MobileCellEditRenderController } from '../mobile-cell-edit.render-controller';
describe('MobileCellEditRenderController', () => {
it('opens the current sheet editor from a mobile double tap', () => {
const currentDbClickedCell$ = new Subject<{ location: { unitId: string } }>();
const currentPointerDownCell$ = new Subject<never>();
const commandService = { executeCommand: vi.fn() };
const controller = new MobileCellEditRenderController(
{ unitId: 'unit-1' } as never,
commandService as never,
{ getEditCellState: vi.fn(() => null) } as never,
{ currentDbClickedCell$, currentPointerDownCell$ } as never
);
currentDbClickedCell$.next({ location: { unitId: 'unit-2' } });
expect(commandService.executeCommand).not.toHaveBeenCalled();
currentDbClickedCell$.next({ location: { unitId: 'unit-1' } });
expect(commandService.executeCommand).toHaveBeenCalledExactlyOnceWith(SetCellEditVisibleOperation.id, {
visible: true,
eventType: DeviceInputEventType.Dblclick,
unitId: 'unit-1',
});
controller.dispose();
});
it('keeps editing the current cell and closes the editor when another cell is pressed', () => {
const currentDbClickedCell$ = new Subject<{ location: { unitId: string } }>();
const currentPointerDownCell$ = new Subject<{
unitId: string;
subUnitId: string;
row: number;
col: number;
}>();
const spreadsheetPointerDown$ = new EventSubject<unknown>();
const rowHeaderPointerDown$ = new EventSubject<unknown>();
const columnHeaderPointerDown$ = new EventSubject<unknown>();
const leftTopPointerDown$ = new EventSubject<unknown>();
const workbook = { getUnitId: vi.fn(() => 'unit-1') };
const commandService = {
executeCommand: vi.fn(),
syncExecuteCommand: vi.fn(),
};
const controller = new MobileCellEditRenderController(
{
unitId: 'unit-1',
isMainScene: true,
unit: { getCurrentUnitOfType: vi.fn(() => workbook) },
mainComponent: { onPointerDown$: spreadsheetPointerDown$ },
components: new Map([
[SHEET_VIEW_KEY.ROW, { onPointerDown$: rowHeaderPointerDown$ }],
[SHEET_VIEW_KEY.COLUMN, { onPointerDown$: columnHeaderPointerDown$ }],
[SHEET_VIEW_KEY.LEFT_TOP, { onPointerDown$: leftTopPointerDown$ }],
]),
scene: {},
engine: {},
} as never,
commandService as never,
{
getEditCellState: vi.fn(() => null),
getEditLocation: vi.fn(() => ({
unitId: 'unit-1',
sheetId: 'sheet-1',
row: 5,
column: 0,
})),
isForceKeepVisible: vi.fn(() => false),
isVisible: vi.fn(() => ({ visible: true })),
} as never,
{ currentDbClickedCell$, currentPointerDownCell$ } as never
);
currentPointerDownCell$.next({
unitId: 'unit-1',
subUnitId: 'sheet-1',
row: 5,
col: 0,
});
expect(commandService.syncExecuteCommand).not.toHaveBeenCalled();
currentPointerDownCell$.next({
unitId: 'unit-1',
subUnitId: 'sheet-1',
row: 6,
col: 0,
});
expect(commandService.syncExecuteCommand).toHaveBeenCalledExactlyOnceWith(
SetCellEditVisibleOperation.id,
{
visible: false,
eventType: DeviceInputEventType.PointerDown,
unitId: 'unit-1',
}
);
controller.dispose();
});
});
@@ -0,0 +1,159 @@
/**
* 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 { EventSubject } from '@univerjs/core';
import { Subject } from 'rxjs';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { MobileHoverRenderController } from '../mobile-hover.render-controller';
interface IMobilePointerTestEvent {
isPrimary: boolean;
offsetX: number;
offsetY: number;
pointerId: number;
}
function createController() {
const onPointerEnter$ = new EventSubject<IMobilePointerTestEvent>();
const onPointerMove$ = new EventSubject<IMobilePointerTestEvent>();
const onPointerDown$ = new EventSubject<IMobilePointerTestEvent>();
const onPointerUp$ = new EventSubject<IMobilePointerTestEvent>();
const onDblclick$ = new EventSubject<IMobilePointerTestEvent>();
const onPointerLeave$ = new EventSubject<IMobilePointerTestEvent>();
const onPointerCancel$ = new EventSubject<IMobilePointerTestEvent>();
const validViewportScrollInfo$ = new Subject<unknown>();
const contextValues = new Map<string, boolean>();
const hoverManagerService = {
triggerMouseMove: vi.fn(),
triggerPointerDown: vi.fn(),
triggerPointerUp: vi.fn(),
triggerClick: vi.fn(),
triggerDbClick: vi.fn(),
triggerScroll: vi.fn(),
};
const controller = new MobileHoverRenderController(
{
mainComponent: {
onPointerEnter$,
onPointerMove$,
onPointerDown$,
onPointerUp$,
onDblclick$,
onPointerLeave$,
},
components: new Map(),
scene: { onPointerCancel$ },
unitId: 'unit-1',
} as never,
hoverManagerService as never,
{
getCurrentParam: vi.fn(() => ({})),
currentSkeleton$: new Subject(),
} as never,
{ validViewportScrollInfo$ } as never,
{ getContextValue: vi.fn((key: string) => contextValues.get(key) ?? false) } as never
);
return {
controller,
hoverManagerService,
onDblclick$,
onPointerCancel$,
onPointerDown$,
onPointerEnter$,
onPointerLeave$,
onPointerMove$,
onPointerUp$,
validViewportScrollInfo$,
};
}
function pointer(offsetX: number, offsetY: number, pointerId = 1): IMobilePointerTestEvent {
return { isPrimary: true, offsetX, offsetY, pointerId };
}
describe('MobileHoverRenderController', () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => {
vi.runOnlyPendingTimers();
vi.useRealTimers();
});
it('recognizes a double tap with the same 10px tolerance before and between taps', () => {
const {
controller,
hoverManagerService,
onPointerDown$,
onPointerEnter$,
onPointerLeave$,
onPointerMove$,
onPointerUp$,
} = createController();
onPointerDown$.emitEvent(pointer(20, 20));
onPointerMove$.emitEvent(pointer(26, 20));
onPointerUp$.emitEvent(pointer(26, 20));
onPointerLeave$.emitEvent(pointer(26, 20));
onPointerEnter$.emitEvent(pointer(28, 20));
onPointerDown$.emitEvent(pointer(28, 20));
onPointerMove$.emitEvent(pointer(34, 20));
onPointerUp$.emitEvent(pointer(34, 20));
expect(hoverManagerService.triggerClick).toHaveBeenCalledTimes(2);
expect(hoverManagerService.triggerDbClick).toHaveBeenCalledExactlyOnceWith('unit-1', 34, 20);
controller.dispose();
});
it('clears the completed tap after a cancelled or scrolled gesture', () => {
const {
controller,
hoverManagerService,
onPointerCancel$,
onPointerDown$,
onPointerUp$,
validViewportScrollInfo$,
} = createController();
onPointerDown$.emitEvent(pointer(20, 20));
onPointerUp$.emitEvent(pointer(20, 20));
onPointerCancel$.emitEvent(pointer(20, 20));
onPointerDown$.emitEvent(pointer(20, 20));
onPointerUp$.emitEvent(pointer(20, 20));
validViewportScrollInfo$.next({});
onPointerDown$.emitEvent(pointer(20, 20));
onPointerUp$.emitEvent(pointer(20, 20));
expect(hoverManagerService.triggerDbClick).not.toHaveBeenCalled();
controller.dispose();
});
it('keeps an engine double click inside the mobile gesture pipeline', () => {
const { controller, hoverManagerService, onDblclick$ } = createController();
const laterObserver = vi.fn();
onDblclick$.subscribeEvent(laterObserver);
const result = onDblclick$.emitEvent(pointer(20, 20));
expect(hoverManagerService.triggerDbClick).toHaveBeenCalledExactlyOnceWith('unit-1', 20, 20);
expect(laterObserver).not.toHaveBeenCalled();
expect(result.stopPropagation).toBe(true);
controller.dispose();
});
});
@@ -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 type { Nullable, Workbook } from '@univerjs/core';
import type { IRenderContext, IRenderModule } from '@univerjs/engine-render';
import type { IEditorBridgeServiceVisibleParam } from '../../../services/editor-bridge.service';
import type { ISheetObjectParam } from '../../utils/component-tools';
import { Disposable, ICommandService, Inject } from '@univerjs/core';
import { DeviceInputEventType } from '@univerjs/engine-render';
import { isCellImage } from '@univerjs/sheets';
import { SetCellEditVisibleOperation } from '../../../commands/operations/cell-edit.operation';
import { IEditorBridgeService } from '../../../services/editor-bridge.service';
import { HoverManagerService } from '../../../services/hover-manager.service';
import { getSheetObject } from '../../utils/component-tools';
export class MobileCellEditRenderController extends Disposable implements IRenderModule {
constructor(
private readonly _context: IRenderContext<Workbook>,
@ICommandService private readonly _commandService: ICommandService,
@IEditorBridgeService private readonly _editorBridgeService: IEditorBridgeService,
@Inject(HoverManagerService) hoverManagerService: HoverManagerService
) {
super();
this.disposeWithMe(hoverManagerService.currentDbClickedCell$.subscribe((cell) => {
if (cell.location.unitId !== this._context.unitId) {
return;
}
const snapshot = this._editorBridgeService.getEditCellState()?.documentLayoutObject.documentModel?.getSnapshot();
if (isCellImage(snapshot)) {
return;
}
this._commandService.executeCommand(SetCellEditVisibleOperation.id, {
visible: true,
eventType: DeviceInputEventType.Dblclick,
unitId: this._context.unitId,
} as IEditorBridgeServiceVisibleParam);
}));
this.disposeWithMe(hoverManagerService.currentPointerDownCell$.subscribe((cell) => {
if (cell.unitId !== this._context.unitId || this._isCurrentEditCell(cell)) {
return;
}
this._hideEditor();
}));
this._initPointerDownListener();
}
private _initPointerDownListener(): void {
const sheetObject = this._getSheetObject();
if (!sheetObject) {
return;
}
const { spreadsheetColumnHeader, spreadsheetLeftTopPlaceholder, spreadsheetRowHeader } = sheetObject;
const hideEditor = () => this._hideEditor();
this.disposeWithMe(spreadsheetColumnHeader.onPointerDown$.subscribeEvent({ next: hideEditor, priority: -1 }));
this.disposeWithMe(spreadsheetLeftTopPlaceholder.onPointerDown$.subscribeEvent({ next: hideEditor, priority: -1 }));
this.disposeWithMe(spreadsheetRowHeader.onPointerDown$.subscribeEvent({ next: hideEditor, priority: -1 }));
}
private _isCurrentEditCell(cell: { unitId: string; subUnitId: string; row: number; col: number }): boolean {
const editLocation = this._editorBridgeService.getEditLocation();
return editLocation?.unitId === cell.unitId &&
editLocation.sheetId === cell.subUnitId &&
editLocation.row === cell.row &&
editLocation.column === cell.col;
}
private _hideEditor(): void {
if (this._editorBridgeService.isForceKeepVisible() || !this._editorBridgeService.isVisible().visible) {
return;
}
this._commandService.syncExecuteCommand(SetCellEditVisibleOperation.id, {
visible: false,
eventType: DeviceInputEventType.PointerDown,
unitId: this._context.unitId,
});
}
private _getSheetObject(): Nullable<ISheetObjectParam> {
if (!this._context.unit) {
return null;
}
return getSheetObject(this._context.unit, this._context);
}
}
@@ -37,6 +37,7 @@ import { SheetScrollManagerService } from '../../../services/scroll-manager.serv
import { SheetSkeletonManagerService } from '../../../services/sheet-skeleton-manager.service';
const TAP_MOVE_THRESHOLD = 10;
const DOUBLE_TAP_DELAY = 500;
interface IMobileTapState {
offsetX: number;
@@ -57,6 +58,10 @@ function isPrimaryPointer(event: MobilePointerEvent): boolean {
export class MobileHoverRenderController extends Disposable implements IRenderModule {
private _active = false;
private _tapState: Nullable<IMobileTapState> = null;
private _lastCompletedTap: Nullable<IMobileTapState> = null;
private _completedTapTimer: Nullable<ReturnType<typeof setTimeout>> = null;
private _suppressEngineDoubleClick = false;
private _suppressEngineDoubleClickTimer: Nullable<ReturnType<typeof setTimeout>> = null;
get active(): boolean {
return this._active;
@@ -75,10 +80,17 @@ export class MobileHoverRenderController extends Disposable implements IRenderMo
this._initScrollEvent();
}
override dispose(): void {
this._resetTapSequence();
this._clearEngineDoubleClickSuppression();
super.dispose();
}
private _initPointerEvent(): void {
const disposeSet = new DisposableCollection();
const handleSkeletonChange = (skeletonParam: Nullable<ISheetSkeletonManagerParam>) => {
disposeSet.dispose();
this._resetTapSequence();
if (!skeletonParam) {
return;
@@ -102,6 +114,7 @@ export class MobileHoverRenderController extends Disposable implements IRenderMo
handleSkeletonChange(this._sheetSkeletonManagerService.getCurrentParam());
this.disposeWithMe(this._sheetSkeletonManagerService.currentSkeleton$.subscribe(handleSkeletonChange));
this.disposeWithMe(this._context.scene.onPointerCancel$.subscribeEvent(() => this._resetTapSequence()));
this.disposeWithMe(disposeSet);
}
@@ -122,10 +135,22 @@ export class MobileHoverRenderController extends Disposable implements IRenderMo
this._hoverManagerService.triggerPointerUp(unitId, event);
if (this._finishTap(event)) {
this._hoverManagerService.triggerClick(unitId, event.offsetX, event.offsetY);
this._completeTap(unitId, event);
}
}));
disposeSet.add(mainComponent.onDblclick$.subscribeEvent((event) => {
this._hoverManagerService.triggerDbClick(unitId, event.offsetX, event.offsetY);
disposeSet.add(mainComponent.onDblclick$.subscribeEvent({
next: ([event, state]) => {
state.stopPropagation();
state.skipNextObservers = true;
if (this._suppressEngineDoubleClick) {
this._clearEngineDoubleClickSuppression();
return;
}
this._resetCompletedTap();
this._hoverManagerService.triggerDbClick(unitId, event.offsetX, event.offsetY);
},
priority: -1,
}));
disposeSet.add(mainComponent.onPointerLeave$.subscribeEvent(() => {
this._active = false;
@@ -179,7 +204,7 @@ export class MobileHoverRenderController extends Disposable implements IRenderMo
this._contextService.getContextValue(MOBILE_PINCH_ZOOMING) ||
this._contextService.getContextValue(MOBILE_EXPANDING_SELECTION)
) {
this._tapState = null;
this._resetTapSequence();
return;
}
@@ -200,7 +225,7 @@ export class MobileHoverRenderController extends Disposable implements IRenderMo
Math.abs(event.offsetX - this._tapState.offsetX) > TAP_MOVE_THRESHOLD ||
Math.abs(event.offsetY - this._tapState.offsetY) > TAP_MOVE_THRESHOLD
) {
this._tapState = null;
this._resetTapSequence();
}
}
@@ -209,19 +234,72 @@ export class MobileHoverRenderController extends Disposable implements IRenderMo
this._tapState = null;
const pointerId = getPointerId(event);
if (!tapState || (pointerId != null && pointerId !== tapState.pointerId)) {
this._resetCompletedTap();
return false;
}
return Math.abs(event.offsetX - tapState.offsetX) <= TAP_MOVE_THRESHOLD
const completed = Math.abs(event.offsetX - tapState.offsetX) <= TAP_MOVE_THRESHOLD
&& Math.abs(event.offsetY - tapState.offsetY) <= TAP_MOVE_THRESHOLD
&& !this._contextService.getContextValue(MOBILE_PINCH_ZOOMING)
&& !this._contextService.getContextValue(MOBILE_EXPANDING_SELECTION)
&& !this._contextService.getContextValue(MOBILE_TRIGGER_CONTEXT_MENU);
if (!completed) {
this._resetCompletedTap();
}
return completed;
}
private _completeTap(unitId: string, event: MobilePointerEvent): void {
const lastCompletedTap = this._lastCompletedTap;
if (
lastCompletedTap &&
Math.abs(event.offsetX - lastCompletedTap.offsetX) <= TAP_MOVE_THRESHOLD &&
Math.abs(event.offsetY - lastCompletedTap.offsetY) <= TAP_MOVE_THRESHOLD
) {
this._resetCompletedTap();
this._suppressEngineDoubleClick = true;
this._suppressEngineDoubleClickTimer = setTimeout(() => {
this._suppressEngineDoubleClick = false;
this._suppressEngineDoubleClickTimer = null;
});
this._hoverManagerService.triggerDbClick(unitId, event.offsetX, event.offsetY);
return;
}
this._resetCompletedTap();
this._lastCompletedTap = {
offsetX: event.offsetX,
offsetY: event.offsetY,
pointerId: getPointerId(event),
};
this._completedTapTimer = setTimeout(() => this._resetCompletedTap(), DOUBLE_TAP_DELAY);
}
private _resetCompletedTap(): void {
if (this._completedTapTimer != null) {
clearTimeout(this._completedTapTimer);
this._completedTapTimer = null;
}
this._lastCompletedTap = null;
}
private _resetTapSequence(): void {
this._tapState = null;
this._resetCompletedTap();
}
private _clearEngineDoubleClickSuppression(): void {
if (this._suppressEngineDoubleClickTimer != null) {
clearTimeout(this._suppressEngineDoubleClickTimer);
this._suppressEngineDoubleClickTimer = null;
}
this._suppressEngineDoubleClick = false;
}
private _initScrollEvent(): void {
this.disposeWithMe(this._scrollManagerService.validViewportScrollInfo$.subscribe(() => {
this._tapState = null;
this._resetTapSequence();
this._hoverManagerService.triggerScroll();
}));
}
@@ -0,0 +1,41 @@
/**
* 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 } from '@univerjs/ui';
import { describe, expect, it } from 'vitest';
import { mobileMenuSchema } from '../mobile-schema';
import { menuSchema } from '../schema';
interface IContextMenuGroupSchema {
mobileLayout?: 'grid' | 'list';
quickLayout?: 'icon' | 'tile';
}
function getQuickGroup(schema: MenuSchemaType): IContextMenuGroupSchema {
const contextMenu = schema as Record<string, MenuSchemaType>;
const mainArea = contextMenu[ContextMenuPosition.MAIN_AREA] as Record<string, MenuSchemaType>;
return mainArea[ContextMenuGroup.QUICK] as IContextMenuGroupSchema;
}
describe('mobile menu schema', () => {
it('keeps the mobile clipboard group in a list without changing the desktop tile layout', () => {
expect(getQuickGroup(mobileMenuSchema)).toMatchObject({ mobileLayout: 'list' });
expect(getQuickGroup(mobileMenuSchema).quickLayout).toBeUndefined();
expect(getQuickGroup(menuSchema)).toMatchObject({ quickLayout: 'tile' });
expect(getQuickGroup(menuSchema).mobileLayout).toBeUndefined();
});
});
@@ -0,0 +1,857 @@
/**
* 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 {
AddWorksheetMergeAllCommand,
AddWorksheetMergeCommand,
AddWorksheetMergeHorizontalCommand,
AddWorksheetMergeVerticalCommand,
CancelFrozenCommand,
ClearSelectionAllCommand,
ClearSelectionContentCommand,
ClearSelectionFormatCommand,
CopySheetCommand,
InsertColBeforeCommand,
InsertMultiColsLeftCommand,
InsertMultiColsRightCommand,
InsertMultiRowsAboveCommand,
InsertMultiRowsAfterCommand,
InsertRowBeforeCommand,
RemoveWorksheetMergeCommand,
ResetBackgroundColorCommand,
ResetTextColorCommand,
SetBackgroundColorCommand,
SetBorderBasicCommand,
SetColWidthCommand,
SetHorizontalTextAlignCommand,
SetRowHeightCommand,
SetSelectedColsVisibleCommand,
SetSelectedRowsVisibleCommand,
SetShrinkToFitCommand,
SetTabColorCommand,
SetTextRotationCommand,
SetTextWrapCommand,
SetVerticalTextAlignCommand,
SetWorksheetHideCommand,
SetWorksheetRowIsAutoHeightCommand,
ToggleGridlinesCommand,
} from '@univerjs/sheets';
import {
ContextMenuGroup,
ContextMenuPosition,
RibbonPosition,
RibbonStartGroup,
RibbonViewGroup,
ToggleFullscreenOperation,
} from '@univerjs/ui';
import {
SheetCopyCommand,
SheetCutCommand,
SheetPasteBesidesBorderCommand,
SheetPasteColWidthCommand,
SheetPasteCommand,
SheetPasteFormatCommand,
SheetPasteValueCommand,
} from '../commands/commands/clipboard.command';
import { DeleteRangeMoveLeftConfirmCommand } from '../commands/commands/delete-range-move-left-confirm.command';
import { DeleteRangeMoveUpConfirmCommand } from '../commands/commands/delete-range-move-up-confirm.command';
import { HideColConfirmCommand, HideRowConfirmCommand } from '../commands/commands/hide-row-col-confirm.command';
import {
SetRangeBoldCommand,
SetRangeFontDecreaseCommand,
SetRangeFontFamilyCommand,
SetRangeFontIncreaseCommand,
SetRangeFontSizeCommand,
SetRangeItalicCommand,
SetRangeStrickThroughCommand,
SetRangeTextColorCommand,
SetRangeUnderlineCommand,
} from '../commands/commands/inline-format.command';
import { InsertRangeMoveDownConfirmCommand } from '../commands/commands/insert-range-move-down-confirm.command';
import { InsertRangeMoveRightConfirmCommand } from '../commands/commands/insert-range-move-right-confirm.command';
import {
AddRangeProtectionFromContextMenuCommand,
AddRangeProtectionFromSheetBarCommand,
AddRangeProtectionFromToolbarCommand,
DeleteRangeProtectionFromContextMenuCommand,
SetRangeProtectionFromContextMenuCommand,
ViewSheetPermissionFromContextMenuCommand,
ViewSheetPermissionFromSheetBarCommand,
} from '../commands/commands/range-protection.command';
import { RemoveColConfirmCommand, RemoveRowConfirmCommand } from '../commands/commands/remove-row-col-confirm.command';
import { RemoveSheetConfirmCommand } from '../commands/commands/remove-sheet-confirm.command';
import { SetOnceFormatPainterCommand } from '../commands/commands/set-format-painter.command';
import {
SetColumnFrozenCommand,
SetFirstColumnFrozenCommand,
SetFirstRowFrozenCommand,
SetRowFrozenCommand,
SetSelectionFrozenCommand,
} from '../commands/commands/set-frozen.command';
import { SetWorksheetColAutoWidthCommand } from '../commands/commands/set-worksheet-auto-col-width.command';
import { ShowMenuListCommand } from '../commands/commands/unhide.command';
import {
ChangeSheetProtectionFromSheetBarCommand,
DeleteWorksheetProtectionFormSheetBarCommand,
} from '../commands/commands/worksheet-protection.command';
import { RenameSheetOperation } from '../commands/operations/rename-sheet.operation';
import { CellBorderSelectorMenuItemFactory } from './border.menu';
import {
CLEAR_SELECTION_MENU_ID,
ClearSelectionAllMenuItemFactory,
ClearSelectionAllToolbarMenuItemFactory,
ClearSelectionContentMenuItemFactory,
ClearSelectionFormatMenuItemFactory,
ClearSelectionMenuItemFactory,
} from './clear.menu';
import {
DELETE_RANGE_MENU_ID,
DeleteRangeMenuItemFactory,
DeleteRangeMoveLeftMenuItemFactory,
DeleteRangeMoveUpMenuItemFactory,
RemoveColMenuItemFactory,
RemoveRowMenuItemFactory,
} from './delete.menu';
import {
FontSizeDecreaseMenuItemFactory,
FontSizeIncreaseMenuItemFactory,
FontSizeSelectorMenuItemFactory,
} from './font.menu';
import {
CancelFrozenMenuItemFactory,
FrozenColMenuItemFactory,
FrozenFirstColMenuItemFactory,
FrozenFirstRowMenuItemFactory,
FrozenMenuItemFactory,
FrozenRowMenuItemFactory,
SHEET_FROZEN_COLUMN_HEADER_MENU_ID,
SHEET_FROZEN_MENU_ID,
SHEET_FROZEN_ROW_HEADER_MENU_ID,
SHEET_FROZEN_TOOLBAR_MENU_ID,
SheetFrozenColumnHeaderMenuItemFactory,
SheetFrozenMenuItemFactory,
SheetFrozenRowHeaderMenuItemFactory,
SheetFrozenToolbarMenuItemFactory,
} from './frozen.menu';
import { FullscreenMenuItemFactory } from './fullscreen.menu';
import { ToggleGridlinesMenuFactory } from './gridlines.menu';
import {
CELL_INSERT_MENU_ID,
CellInsertMenuItemFactory,
InsertColLeftCellMenuItemFactory,
InsertMultiColsLeftHeaderMenuItemFactory,
InsertMultiColsRightHeaderMenuItemFactory,
InsertMultiRowsAboveHeaderMenuItemFactory,
InsertMultiRowsAfterHeaderMenuItemFactory,
InsertRangeMoveDownMenuItemFactory,
InsertRangeMoveRightMenuItemFactory,
InsertRowBeforeCellMenuItemFactory,
} from './insert.menu';
import {
BackgroundColorSelectorMenuItemFactory,
BoldMenuItemFactory,
ColAutoWidthMenuItemFactory,
COPY_SPECIAL_MENU_ID,
CopyMenuItemFactory,
CopySpacialMenuItemFactory,
CutMenuItemFactory,
FitContentMenuItemFactory,
FontFamilySelectorMenuItemFactory,
FormatPainterMenuItemFactory,
HideColMenuItemFactory,
HideRowMenuItemFactory,
HorizontalAlignMenuItemFactory,
ItalicMenuItemFactory,
PASTE_SPECIAL_MENU_ID,
PasteBesidesBorderMenuItemFactory,
PasteColWidthMenuItemFactory,
PasteFormatMenuItemFactory,
PasteMenuItemFactory,
PasteSpacialMenuItemFactory,
PasteValueMenuItemFactory,
ResetBackgroundColorMenuItemFactory,
ResetTextColorMenuItemFactory,
SetColWidthMenuItemFactory,
SetRowHeightMenuItemFactory,
ShowColMenuItemFactory,
ShowRowMenuItemFactory,
ShrinkToFitMenuItemFactory,
StrikeThroughMenuItemFactory,
TextColorSelectorMenuItemFactory,
TextRotateMenuItemFactory,
UnderlineMenuItemFactory,
VerticalAlignMenuItemFactory,
WrapTextMenuItemFactory,
} from './menu';
import {
CellMergeAllMenuItemFactory,
CellMergeCancelMenuItemFactory,
CellMergeHorizontalMenuItemFactory,
CellMergeMenuItemFactory,
CellMergeVerticalMenuItemFactory,
} from './merge.menu';
import {
SHEET_PERMISSION_CONTEXT_MENU_ID,
sheetPermissionAddProtectContextMenuFactory,
sheetPermissionChangeSheetPermissionSheetBarMenuFactory,
sheetPermissionContextMenuFactory,
sheetPermissionEditProtectContextMenuFactory,
sheetPermissionProtectSheetInSheetBarMenuFactory,
sheetPermissionRemoveProtectContextMenuFactory,
sheetPermissionRemoveProtectionSheetBarMenuFactory,
sheetPermissionToolbarMenuFactory,
sheetPermissionViewAllProtectRuleContextMenuFactory,
sheetPermissionViewAllProtectRuleSheetBarMenuFactory,
} from './permission.menu';
import {
ChangeColorSheetMenuItemFactory,
CopySheetMenuItemFactory,
DeleteSheetMenuItemFactory,
HideSheetMenuItemFactory,
RenameSheetMenuItemFactory,
ShowMenuItemFactory,
} from './sheet.menu';
import { Text2NumberContextMenuItemFactory, TEXT_TO_NUMBER_CONTEXT_MENU_ID } from './text-to-number.menu';
import { ZOOM_RATIO_MENU_ID, ZoomRatioMenuItemFactory } from './zoom.menu';
export const mobileMenuSchema: MenuSchemaType = {
[RibbonPosition.START]: {
[RibbonStartGroup.HISTORY]: {
[SetOnceFormatPainterCommand.id]: {
order: 2,
menuItemFactory: FormatPainterMenuItemFactory,
},
[ClearSelectionAllCommand.id]: {
order: 3,
menuItemFactory: ClearSelectionAllToolbarMenuItemFactory,
},
},
[RibbonStartGroup.FORMAT]: {
[SetRangeFontFamilyCommand.id]: {
order: 1,
menuItemFactory: FontFamilySelectorMenuItemFactory,
},
[SetRangeFontSizeCommand.id]: {
order: 2,
menuItemFactory: FontSizeSelectorMenuItemFactory,
},
[SetRangeFontIncreaseCommand.id]: {
order: 2.1,
menuItemFactory: FontSizeIncreaseMenuItemFactory,
},
[SetRangeFontDecreaseCommand.id]: {
order: 2.2,
menuItemFactory: FontSizeDecreaseMenuItemFactory,
},
[SetRangeBoldCommand.id]: {
order: 5,
menuItemFactory: BoldMenuItemFactory,
},
[SetRangeItalicCommand.id]: {
order: 6,
menuItemFactory: ItalicMenuItemFactory,
},
[SetRangeUnderlineCommand.id]: {
order: 7,
menuItemFactory: UnderlineMenuItemFactory,
},
[SetRangeStrickThroughCommand.id]: {
order: 8,
menuItemFactory: StrikeThroughMenuItemFactory,
},
[SetRangeTextColorCommand.id]: {
order: 9,
menuItemFactory: TextColorSelectorMenuItemFactory,
[ResetTextColorCommand.id]: {
order: 0,
menuItemFactory: ResetTextColorMenuItemFactory,
},
},
[SetBackgroundColorCommand.id]: {
order: 10,
menuItemFactory: BackgroundColorSelectorMenuItemFactory,
[ResetBackgroundColorCommand.id]: {
order: 0,
menuItemFactory: ResetBackgroundColorMenuItemFactory,
},
},
[SetBorderBasicCommand.id]: {
order: 11,
menuItemFactory: CellBorderSelectorMenuItemFactory,
},
},
[RibbonStartGroup.LAYOUT]: {
[SetHorizontalTextAlignCommand.id]: {
order: 0,
menuItemFactory: HorizontalAlignMenuItemFactory,
},
[SetVerticalTextAlignCommand.id]: {
order: 3,
menuItemFactory: VerticalAlignMenuItemFactory,
},
[SetTextWrapCommand.id]: {
order: 6,
menuItemFactory: WrapTextMenuItemFactory,
},
[SetShrinkToFitCommand.id]: {
order: 7,
menuItemFactory: ShrinkToFitMenuItemFactory,
},
[SetTextRotationCommand.id]: {
order: 8,
menuItemFactory: TextRotateMenuItemFactory,
},
[AddWorksheetMergeCommand.id]: {
order: 9,
menuItemFactory: CellMergeMenuItemFactory,
[AddWorksheetMergeAllCommand.id]: {
order: 0,
menuItemFactory: CellMergeAllMenuItemFactory,
},
[AddWorksheetMergeVerticalCommand.id]: {
order: 1,
menuItemFactory: CellMergeVerticalMenuItemFactory,
},
[AddWorksheetMergeHorizontalCommand.id]: {
order: 2,
menuItemFactory: CellMergeHorizontalMenuItemFactory,
},
[RemoveWorksheetMergeCommand.id]: {
order: 3,
menuItemFactory: CellMergeCancelMenuItemFactory,
},
},
},
[RibbonStartGroup.OTHERS]: {
[AddRangeProtectionFromToolbarCommand.id]: {
order: 0,
menuItemFactory: sheetPermissionToolbarMenuFactory,
},
},
},
[RibbonPosition.VIEW]: {
[RibbonViewGroup.DISPLAY]: {
[SHEET_FROZEN_TOOLBAR_MENU_ID]: {
order: 0,
menuItemFactory: SheetFrozenToolbarMenuItemFactory,
},
[ZOOM_RATIO_MENU_ID]: {
order: 1,
menuItemFactory: ZoomRatioMenuItemFactory,
},
[ToggleFullscreenOperation.id]: {
order: 2,
menuItemFactory: FullscreenMenuItemFactory,
},
},
[RibbonViewGroup.VISIBILITY]: {
[ToggleGridlinesCommand.id]: {
order: 0,
menuItemFactory: ToggleGridlinesMenuFactory,
},
},
},
[ContextMenuPosition.MAIN_AREA]: {
[ContextMenuGroup.QUICK]: ({
mobileLayout: 'list',
[SheetCopyCommand.name]: {
order: 0,
menuItemFactory: CopyMenuItemFactory,
},
[SheetCutCommand.name]: {
order: 1,
menuItemFactory: CutMenuItemFactory,
},
[SheetPasteCommand.name]: {
order: 2,
menuItemFactory: PasteMenuItemFactory,
},
} as MenuSchemaType),
[ContextMenuGroup.FORMAT]: {
[COPY_SPECIAL_MENU_ID]: {
order: 0,
menuItemFactory: CopySpacialMenuItemFactory,
},
[PASTE_SPECIAL_MENU_ID]: {
order: 1,
menuItemFactory: PasteSpacialMenuItemFactory,
[SheetPasteValueCommand.id]: {
order: 0,
menuItemFactory: PasteValueMenuItemFactory,
},
[SheetPasteFormatCommand.id]: {
order: 1,
menuItemFactory: PasteFormatMenuItemFactory,
},
[SheetPasteColWidthCommand.id]: {
order: 2,
menuItemFactory: PasteColWidthMenuItemFactory,
},
[SheetPasteBesidesBorderCommand.id]: {
order: 3,
menuItemFactory: PasteBesidesBorderMenuItemFactory,
},
},
[CLEAR_SELECTION_MENU_ID]: {
order: 2,
menuItemFactory: ClearSelectionMenuItemFactory,
[ClearSelectionContentCommand.id]: {
order: 0,
menuItemFactory: ClearSelectionContentMenuItemFactory,
},
[ClearSelectionFormatCommand.id]: {
order: 1,
menuItemFactory: ClearSelectionFormatMenuItemFactory,
},
[ClearSelectionAllCommand.id]: {
order: 2,
menuItemFactory: ClearSelectionAllMenuItemFactory,
},
},
[TEXT_TO_NUMBER_CONTEXT_MENU_ID]: {
order: 3,
menuItemFactory: Text2NumberContextMenuItemFactory,
},
},
[ContextMenuGroup.LAYOUT]: {
order: 1,
[CELL_INSERT_MENU_ID]: {
order: 0,
menuItemFactory: CellInsertMenuItemFactory,
[InsertRowBeforeCommand.id]: {
order: 0,
menuItemFactory: InsertRowBeforeCellMenuItemFactory,
},
[InsertColBeforeCommand.id]: {
order: 1,
menuItemFactory: InsertColLeftCellMenuItemFactory,
},
[InsertRangeMoveRightConfirmCommand.id]: {
order: 2,
menuItemFactory: InsertRangeMoveRightMenuItemFactory,
},
[InsertRangeMoveDownConfirmCommand.id]: {
order: 3,
menuItemFactory: InsertRangeMoveDownMenuItemFactory,
},
},
[DELETE_RANGE_MENU_ID]: {
order: 0,
menuItemFactory: DeleteRangeMenuItemFactory,
[RemoveColConfirmCommand.id]: {
order: 0,
menuItemFactory: RemoveColMenuItemFactory,
},
[RemoveRowConfirmCommand.id]: {
order: 1,
menuItemFactory: RemoveRowMenuItemFactory,
},
[DeleteRangeMoveLeftConfirmCommand.id]: {
order: 2,
menuItemFactory: DeleteRangeMoveLeftMenuItemFactory,
},
[DeleteRangeMoveUpConfirmCommand.id]: {
order: 3,
menuItemFactory: DeleteRangeMoveUpMenuItemFactory,
},
},
[SHEET_FROZEN_MENU_ID]: {
order: 2,
menuItemFactory: SheetFrozenMenuItemFactory,
[SetSelectionFrozenCommand.id]: {
order: 0,
menuItemFactory: FrozenMenuItemFactory,
},
[SetRowFrozenCommand.id]: {
order: 1,
menuItemFactory: FrozenRowMenuItemFactory,
},
[SetColumnFrozenCommand.id]: {
order: 2,
menuItemFactory: FrozenColMenuItemFactory,
},
[SetFirstRowFrozenCommand.id]: {
order: 3,
menuItemFactory: FrozenFirstRowMenuItemFactory,
},
[SetFirstColumnFrozenCommand.id]: {
order: 4,
menuItemFactory: FrozenFirstColMenuItemFactory,
},
[CancelFrozenCommand.id]: {
order: 5,
menuItemFactory: CancelFrozenMenuItemFactory,
},
},
[SHEET_PERMISSION_CONTEXT_MENU_ID]: {
order: 3,
menuItemFactory: sheetPermissionContextMenuFactory,
[AddRangeProtectionFromContextMenuCommand.id]: {
order: 0,
menuItemFactory: sheetPermissionAddProtectContextMenuFactory,
},
[SetRangeProtectionFromContextMenuCommand.id]: {
order: 1,
menuItemFactory: sheetPermissionEditProtectContextMenuFactory,
},
[DeleteRangeProtectionFromContextMenuCommand.id]: {
order: 2,
menuItemFactory: sheetPermissionRemoveProtectContextMenuFactory,
},
[ViewSheetPermissionFromContextMenuCommand.id]: {
order: 3,
menuItemFactory: sheetPermissionViewAllProtectRuleContextMenuFactory,
},
},
},
[ContextMenuGroup.DATA]: {
order: 2,
},
[ContextMenuGroup.OTHERS]: {
order: 3,
},
},
[ContextMenuPosition.COL_HEADER]: {
[ContextMenuGroup.QUICK]: ({
mobileLayout: 'list',
[SheetCopyCommand.name]: {
order: 0,
menuItemFactory: CopyMenuItemFactory,
},
[SheetCutCommand.name]: {
order: 1,
menuItemFactory: CutMenuItemFactory,
},
[SheetPasteCommand.name]: {
order: 2,
menuItemFactory: PasteMenuItemFactory,
},
} as MenuSchemaType),
[ContextMenuGroup.FORMAT]: {
[COPY_SPECIAL_MENU_ID]: {
order: 0,
menuItemFactory: CopySpacialMenuItemFactory,
},
[PASTE_SPECIAL_MENU_ID]: {
order: 1,
menuItemFactory: PasteSpacialMenuItemFactory,
[SheetPasteValueCommand.id]: {
order: 0,
menuItemFactory: PasteValueMenuItemFactory,
},
[SheetPasteFormatCommand.id]: {
order: 1,
menuItemFactory: PasteFormatMenuItemFactory,
},
[SheetPasteColWidthCommand.id]: {
order: 2,
menuItemFactory: PasteColWidthMenuItemFactory,
},
[SheetPasteBesidesBorderCommand.id]: {
order: 3,
menuItemFactory: PasteBesidesBorderMenuItemFactory,
},
},
[CLEAR_SELECTION_MENU_ID]: {
order: 2,
menuItemFactory: ClearSelectionMenuItemFactory,
[ClearSelectionContentCommand.id]: {
order: 0,
menuItemFactory: ClearSelectionContentMenuItemFactory,
},
[ClearSelectionFormatCommand.id]: {
order: 1,
menuItemFactory: ClearSelectionFormatMenuItemFactory,
},
[ClearSelectionAllCommand.id]: {
order: 2,
menuItemFactory: ClearSelectionAllMenuItemFactory,
},
},
[TEXT_TO_NUMBER_CONTEXT_MENU_ID]: {
order: 3,
menuItemFactory: Text2NumberContextMenuItemFactory,
},
},
[ContextMenuGroup.LAYOUT]: {
order: 1,
[InsertMultiColsLeftCommand.id]: {
order: 0,
menuItemFactory: InsertMultiColsLeftHeaderMenuItemFactory,
},
[InsertMultiColsRightCommand.id]: {
order: 0,
menuItemFactory: InsertMultiColsRightHeaderMenuItemFactory,
},
[HideColConfirmCommand.id]: {
order: 1,
menuItemFactory: HideColMenuItemFactory,
},
[SetSelectedColsVisibleCommand.id]: {
order: 2,
menuItemFactory: ShowColMenuItemFactory,
},
[RemoveColConfirmCommand.id]: {
order: 2,
menuItemFactory: RemoveColMenuItemFactory,
},
[SetColWidthCommand.id]: {
order: 3,
menuItemFactory: SetColWidthMenuItemFactory,
},
[SetWorksheetColAutoWidthCommand.id]: {
order: 4,
menuItemFactory: ColAutoWidthMenuItemFactory,
},
[SHEET_FROZEN_COLUMN_HEADER_MENU_ID]: {
order: 5,
menuItemFactory: SheetFrozenColumnHeaderMenuItemFactory,
[SetColumnFrozenCommand.id]: {
order: 2,
menuItemFactory: FrozenColMenuItemFactory,
},
[SetFirstColumnFrozenCommand.id]: {
order: 4,
menuItemFactory: FrozenFirstColMenuItemFactory,
},
[CancelFrozenCommand.id]: {
order: 5,
menuItemFactory: CancelFrozenMenuItemFactory,
},
},
[SHEET_PERMISSION_CONTEXT_MENU_ID]: {
order: 6,
menuItemFactory: sheetPermissionContextMenuFactory,
[AddRangeProtectionFromContextMenuCommand.id]: {
order: 0,
menuItemFactory: sheetPermissionAddProtectContextMenuFactory,
},
[SetRangeProtectionFromContextMenuCommand.id]: {
order: 1,
menuItemFactory: sheetPermissionEditProtectContextMenuFactory,
},
[DeleteRangeProtectionFromContextMenuCommand.id]: {
order: 2,
menuItemFactory: sheetPermissionRemoveProtectContextMenuFactory,
},
[ViewSheetPermissionFromContextMenuCommand.id]: {
order: 3,
menuItemFactory: sheetPermissionViewAllProtectRuleContextMenuFactory,
},
},
},
[ContextMenuGroup.DATA]: {
order: 2,
},
[ContextMenuGroup.OTHERS]: {
order: 3,
},
},
[ContextMenuPosition.ROW_HEADER]: {
[ContextMenuGroup.QUICK]: ({
mobileLayout: 'list',
[SheetCopyCommand.name]: {
order: 0,
menuItemFactory: CopyMenuItemFactory,
},
[SheetCutCommand.name]: {
order: 1,
menuItemFactory: CutMenuItemFactory,
},
[SheetPasteCommand.name]: {
order: 2,
menuItemFactory: PasteMenuItemFactory,
},
} as MenuSchemaType),
[ContextMenuGroup.FORMAT]: {
[COPY_SPECIAL_MENU_ID]: {
order: 0,
menuItemFactory: CopySpacialMenuItemFactory,
},
[PASTE_SPECIAL_MENU_ID]: {
order: 1,
menuItemFactory: PasteSpacialMenuItemFactory,
[SheetPasteValueCommand.id]: {
order: 0,
menuItemFactory: PasteValueMenuItemFactory,
},
[SheetPasteFormatCommand.id]: {
order: 1,
menuItemFactory: PasteFormatMenuItemFactory,
},
[SheetPasteColWidthCommand.id]: {
order: 2,
menuItemFactory: PasteColWidthMenuItemFactory,
},
[SheetPasteBesidesBorderCommand.id]: {
order: 3,
menuItemFactory: PasteBesidesBorderMenuItemFactory,
},
},
[CLEAR_SELECTION_MENU_ID]: {
order: 2,
menuItemFactory: ClearSelectionMenuItemFactory,
[ClearSelectionContentCommand.id]: {
order: 0,
menuItemFactory: ClearSelectionContentMenuItemFactory,
},
[ClearSelectionFormatCommand.id]: {
order: 1,
menuItemFactory: ClearSelectionFormatMenuItemFactory,
},
[ClearSelectionAllCommand.id]: {
order: 2,
menuItemFactory: ClearSelectionAllMenuItemFactory,
},
},
[TEXT_TO_NUMBER_CONTEXT_MENU_ID]: {
order: 3,
menuItemFactory: Text2NumberContextMenuItemFactory,
},
},
[ContextMenuGroup.LAYOUT]: {
order: 1,
[InsertMultiRowsAboveCommand.id]: {
order: 0,
menuItemFactory: InsertMultiRowsAboveHeaderMenuItemFactory,
},
[InsertMultiRowsAfterCommand.id]: {
order: 1,
menuItemFactory: InsertMultiRowsAfterHeaderMenuItemFactory,
},
[RemoveRowConfirmCommand.id]: {
order: 1,
menuItemFactory: RemoveRowMenuItemFactory,
},
[HideRowConfirmCommand.id]: {
order: 2,
menuItemFactory: HideRowMenuItemFactory,
},
[SetSelectedRowsVisibleCommand.id]: {
order: 2,
menuItemFactory: ShowRowMenuItemFactory,
},
[SetRowHeightCommand.id]: {
order: 3,
menuItemFactory: SetRowHeightMenuItemFactory,
},
[SetWorksheetRowIsAutoHeightCommand.id]: {
order: 4,
menuItemFactory: FitContentMenuItemFactory,
},
[SHEET_FROZEN_ROW_HEADER_MENU_ID]: {
order: 5,
menuItemFactory: SheetFrozenRowHeaderMenuItemFactory,
[SetRowFrozenCommand.id]: {
order: 1,
menuItemFactory: FrozenRowMenuItemFactory,
},
[SetFirstRowFrozenCommand.id]: {
order: 3,
menuItemFactory: FrozenFirstRowMenuItemFactory,
},
[CancelFrozenCommand.id]: {
order: 5,
menuItemFactory: CancelFrozenMenuItemFactory,
},
},
[SHEET_PERMISSION_CONTEXT_MENU_ID]: {
order: 6,
menuItemFactory: sheetPermissionContextMenuFactory,
[AddRangeProtectionFromContextMenuCommand.id]: {
order: 0,
menuItemFactory: sheetPermissionAddProtectContextMenuFactory,
},
[SetRangeProtectionFromContextMenuCommand.id]: {
order: 1,
menuItemFactory: sheetPermissionEditProtectContextMenuFactory,
},
[DeleteRangeProtectionFromContextMenuCommand.id]: {
order: 2,
menuItemFactory: sheetPermissionRemoveProtectContextMenuFactory,
},
[ViewSheetPermissionFromContextMenuCommand.id]: {
order: 3,
menuItemFactory: sheetPermissionViewAllProtectRuleContextMenuFactory,
},
},
},
[ContextMenuGroup.DATA]: {
order: 2,
},
[ContextMenuGroup.OTHERS]: {
order: 3,
},
},
[ContextMenuPosition.FOOTER_TABS]: {
[ContextMenuGroup.OTHERS]: {
order: 0,
[RemoveSheetConfirmCommand.id]: {
order: 0,
menuItemFactory: DeleteSheetMenuItemFactory,
},
[CopySheetCommand.id]: {
order: 1,
menuItemFactory: CopySheetMenuItemFactory,
},
[RenameSheetOperation.id]: {
order: 2,
menuItemFactory: RenameSheetMenuItemFactory,
},
[SetTabColorCommand.id]: {
order: 3,
menuItemFactory: ChangeColorSheetMenuItemFactory,
},
[SetWorksheetHideCommand.id]: {
order: 4,
menuItemFactory: HideSheetMenuItemFactory,
},
// [SetWorksheetShowCommand.id]: {
// order: 5,
// menuItemFactory: UnHideSheetMenuItemFactory,
// },
[ShowMenuListCommand.id]: {
order: 6,
menuItemFactory: ShowMenuItemFactory,
},
[AddRangeProtectionFromSheetBarCommand.id]: {
order: 7,
menuItemFactory: sheetPermissionProtectSheetInSheetBarMenuFactory,
},
[DeleteWorksheetProtectionFormSheetBarCommand.id]: {
order: 8,
menuItemFactory: sheetPermissionRemoveProtectionSheetBarMenuFactory,
},
[ChangeSheetProtectionFromSheetBarCommand.id]: {
order: 9,
menuItemFactory: sheetPermissionChangeSheetPermissionSheetBarMenuFactory,
},
[ViewSheetPermissionFromSheetBarCommand.id]: {
order: 10,
menuItemFactory: sheetPermissionViewAllProtectRuleSheetBarMenuFactory,
},
},
},
[ContextMenuPosition.FOOTER_MENU]: {
[ContextMenuGroup.OTHERS]: {
[ToggleGridlinesCommand.id]: {
order: 1,
menuItemFactory: ToggleGridlinesMenuFactory,
},
},
},
};
+2
View File
@@ -80,6 +80,7 @@ import { HeaderFreezeRenderController } from './controllers/render-controllers/f
import { HeaderMenuRenderController } from './controllers/render-controllers/header-menu.render-controller';
import { HeaderMoveRenderController } from './controllers/render-controllers/header-move.render-controller';
import { HeaderUnhideRenderController } from './controllers/render-controllers/header-unhide.render-controller';
import { MobileCellEditRenderController } from './controllers/render-controllers/mobile/mobile-cell-edit.render-controller';
import {
SheetContextMenuMobileRenderController,
} from './controllers/render-controllers/mobile/mobile-contextmenu.render-controller';
@@ -297,6 +298,7 @@ export class UniverSheetsMobileUIPlugin extends Plugin {
// editor
[EditorBridgeRenderController],
[MobileCellEditRenderController],
// permission
[SheetPermissionInterceptorCanvasRenderController],
+2
View File
@@ -76,6 +76,7 @@ import {
} from './controllers/permission/sheet-permission-render.controller';
import { ClipboardRenderController } from './controllers/render-controllers/clipboard.render-controller';
import { SheetContextMenuRenderController } from './controllers/render-controllers/contextmenu.render-controller';
import { DesktopCellEditRenderController } from './controllers/render-controllers/desktop-cell-edit.render-controller';
import { EditorBridgeRenderController } from './controllers/render-controllers/editor-bridge.render-controller';
import { FormatPainterRenderController } from './controllers/render-controllers/format-painter.render-controller';
import { HeaderFreezeRenderController } from './controllers/render-controllers/freeze.render-controller';
@@ -302,6 +303,7 @@ export class UniverSheetsUIPlugin extends Plugin {
// editor
[EditorBridgeRenderController],
[DesktopCellEditRenderController],
// permission
[SheetPermissionInterceptorCanvasRenderController],
@@ -66,6 +66,7 @@ import enUS from '../../../locale/en-US';
import { IEditorBridgeService } from '../../../services/editor-bridge.service';
import { FormulaEditorManagerService, IFormulaEditorManagerService } from '../../../services/editor/formula-editor-manager.service';
import { MobileFormulaBar } from '../../mobile/formula-bar/MobileFormulaBar';
import { MobileFormulaBarOverlays } from '../../mobile/formula-bar/MobileFormulaBarControls';
import { FormulaBar } from '../FormulaBar';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
@@ -469,7 +470,65 @@ describe('FormulaBar', () => {
subscription.unsubscribe();
});
it('commits and moves down from the compact mobile formula bar', async () => {
it('prevents mobile formula bar actions from stealing editor focus', async () => {
currentBed = createFormulaBarTestBed();
const forceFormulaMode: boolean[] = [];
const subscription = currentBed.injector.get(IFormulaEditorManagerService).fxBtnClick$.subscribe((value) => {
forceFormulaMode.push(value);
});
const rendered = renderWithDependencies(<MobileFormulaBar />, currentBed.injector);
root = rendered.root;
container = rendered.container;
const formulaButton = getActionElement(rendered.container, 2);
const pointerDownEvents = [0, 1, 2].map(() => new MouseEvent('pointerdown', {
bubbles: true,
cancelable: true,
}));
await act(async () => {
pointerDownEvents.forEach((event, index) => {
getActionElement(rendered.container, index).dispatchEvent(event);
});
await Promise.resolve();
});
await clickElement(formulaButton);
expect(pointerDownEvents.every((event) => event.defaultPrevented)).toBe(true);
expect(forceFormulaMode).toEqual([true]);
expect(currentBed.editorBridgeService.isVisible().visible).toBe(true);
subscription.unsubscribe();
});
it('keeps editor focus while inserting a mobile formula operator', async () => {
currentBed = createFormulaBarTestBed();
const onOperator = vi.fn();
const rendered = renderWithDependencies(
<MobileFormulaBarOverlays
expanded={false}
formulaActive
operatorsVisible
editorId={DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY}
onOperator={onOperator}
/>,
currentBed.injector
);
root = rendered.root;
container = rendered.container;
const commaButton = rendered.container.querySelector<HTMLButtonElement>('button[aria-label=","]');
const pointerDownEvent = new MouseEvent('pointerdown', { bubbles: true, cancelable: true });
if (!commaButton) throw new Error('Expected the comma operator button to be rendered.');
await act(async () => {
commaButton.dispatchEvent(pointerDownEvent);
commaButton.click();
await Promise.resolve();
});
expect(pointerDownEvent.defaultPrevented).toBe(true);
expect(onOperator).toHaveBeenCalledWith(',');
});
it('commits, moves down, and exits editing from the compact mobile formula bar', async () => {
currentBed = createFormulaBarTestBed();
const rendered = renderWithDependencies(<MobileFormulaBar />, currentBed.injector);
root = rendered.root;
@@ -481,7 +540,7 @@ describe('FormulaBar', () => {
await clickElement(getActionElement(rendered.container, 1));
expect(currentBed.mobileSubmit).toHaveBeenCalledOnce();
expect(currentBed.editorBridgeService.visibleHistory.at(-1)?.visible).toBe(true);
expect(currentBed.editorBridgeService.visibleHistory.at(-1)?.visible).toBe(false);
});
it('renders the selected cell background on the mobile editor host without locking the canvas color', () => {
@@ -526,8 +585,8 @@ describe('FormulaBar', () => {
await clickElement(getActionElement(rendered.container, 1));
expect(currentBed.mobileSubmit).toHaveBeenCalledOnce();
expect(currentBed.editorBridgeService.visibleHistory.at(-1)?.visible).toBe(true);
expect(rendered.container.querySelector('[data-u-comp="mobile-formula-bar"]')?.getAttribute('data-expanded')).toBe('false');
expect(currentBed.editorBridgeService.visibleHistory.at(-1)?.visible).toBe(false);
expect(rendered.container.querySelector('[data-u-comp="mobile-formula-bar"]')).toBeNull();
});
it('fills the mobile viewport while the formula bar is expanded', async () => {
@@ -28,7 +28,7 @@ import {
IContextService,
LocaleService,
} from '@univerjs/core';
import { clsx, resetButtonClassName } from '@univerjs/design';
import { clsx, resetButtonClassName, useMobileKeyboardViewportLayout } from '@univerjs/design';
import { IEditorService } from '@univerjs/docs-ui';
import { DeviceInputEventType } from '@univerjs/engine-render';
import { KeyboardIcon, MenuIcon, MoreLeftIcon } from '@univerjs/icons';
@@ -40,14 +40,13 @@ import {
IMenuManagerService,
IRibbonService,
MobileDrawer,
MobileKeyboardInsetContext,
MobileMenu,
RibbonPosition,
RibbonStartGroup,
useDependency,
useObservable,
} from '@univerjs/ui';
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { map, startWith } from 'rxjs';
import { ScrollCommand, SetScrollRelativeCommand } from '../../../commands/commands/set-scroll.command';
import { SetCellEditVisibleOperation } from '../../../commands/operations/cell-edit.operation';
@@ -156,7 +155,6 @@ export function MobileSheetActionPanel() {
}
function MobileSheetActionPanelContent() {
const keyboardInset = useContext(MobileKeyboardInsetContext);
const workbook = useActiveWorkbook();
const commandService = useDependency(ICommandService);
const contextService = useDependency(IContextService);
@@ -204,8 +202,10 @@ function MobileSheetActionPanelContent() {
const [styleViewStack, setStyleViewStack] = useState<MobileStyleView[]>([]);
const [menuNavigation, setMenuNavigation] = useState<IMobileMenuNavigation | null>(null);
const [recentColors, setRecentColors] = useState<string[]>([]);
const editingMenuRef = useRef<HTMLButtonElement>(null);
const panelRef = useRef<HTMLElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const keyboardLayout = useMobileKeyboardViewportLayout(editingMenuRef);
const currentStyleView = styleViewStack[styleViewStack.length - 1] ?? null;
const nestedTitle = currentStyleView?.title ?? menuNavigation?.title;
@@ -379,6 +379,7 @@ function MobileSheetActionPanelContent() {
if (editing) {
return (
<button
ref={editingMenuRef}
type="button"
aria-label={localeService.t<LocaleKey>('sheets-ui.mobile.openTools')}
className={clsx(resetButtonClassName, `
@@ -388,7 +389,7 @@ function MobileSheetActionPanelContent() {
active:univer-bg-primary-700
`)}
style={{
bottom: keyboardInset + getMobileEditingMenuBottomOffset(formulaOperatorsVisible),
bottom: (keyboardLayout?.bottom ?? 0) + getMobileEditingMenuBottomOffset(formulaOperatorsVisible),
}}
onClick={() => openTools().catch(() => undefined)}
>
@@ -25,7 +25,7 @@ import {
IPermissionService,
LocaleService,
} from '@univerjs/core';
import { borderBottomClassName, clsx } from '@univerjs/design';
import { borderBottomClassName, clsx, useMobileKeyboardViewportLayout } from '@univerjs/design';
import { IEditorService } from '@univerjs/docs-ui';
import { DeviceInputEventType } from '@univerjs/engine-render';
import { DownIcon } from '@univerjs/icons';
@@ -44,13 +44,12 @@ import {
ComponentContainer,
ComponentManager,
KeyCode,
MobileKeyboardInsetContext,
useComponentsOfPart,
useConfigValue,
useDependency,
useObservable,
} from '@univerjs/ui';
import { useContext, useEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { EMPTY, map, merge, of, switchMap } from 'rxjs';
import { SetCellEditVisibleOperation } from '../../../commands/operations/cell-edit.operation';
import { EMBEDDING_FORMULA_EDITOR_COMPONENT_KEY } from '../../../common/keys';
@@ -110,7 +109,6 @@ export function MobileFormulaBar() {
}
function MobileFormulaBarEditor() {
const keyboardInset = useContext(MobileKeyboardInsetContext);
const commandService = useDependency(ICommandService);
const contextService = useDependency(IContextService);
const editorBridgeService = useDependency(IEditorBridgeService);
@@ -142,6 +140,7 @@ function MobileFormulaBarEditor() {
const config = useConfigValue<IUniverSheetsUIConfig>(SHEETS_UI_PLUGIN_CONFIG_KEY);
const disableEdit = config?.disableEdit;
const editorRef = useRef<HTMLDivElement>(null);
const formulaBarRef = useRef<HTMLDivElement>(null);
const shouldSkipFocusRef = useRef(false);
const [expanded, setExpanded] = useState(false);
const [mobileFxRequest, setMobileFxRequest] = useState(0);
@@ -149,6 +148,7 @@ function MobileFormulaBarEditor() {
const [mobileFormulaActive, setMobileFormulaActive] = useState(false);
const [mobileOperatorRequest, setMobileOperatorRequest] = useState({ id: 0, value: '' });
const mobileFormulaOperatorsVisible = mobileFormulaActive && !expanded;
const keyboardLayout = useMobileKeyboardViewportLayout(formulaBarRef);
const disableInfo = useObservable(
() => {
@@ -266,6 +266,14 @@ function MobileFormulaBarEditor() {
async function handleConfirmBtnClick() {
const submitted = await commandService.executeCommand(MOBILE_FORMULA_BAR_SUBMIT_COMMAND_ID);
if (submitted) {
const visibleState = editorBridgeService.isVisible();
if (visibleState.visible) {
await commandService.executeCommand(SetCellEditVisibleOperation.id, {
...visibleState,
visible: false,
eventType: DeviceInputEventType.PointerDown,
});
}
contextService.setContextValue(MOBILE_FX_EDITOR_EXPANDED, false);
setExpanded(false);
}
@@ -277,6 +285,7 @@ function MobileFormulaBarEditor() {
if (formulaText.startsWith('=')) {
setMobileFxRequest((value) => value + 1);
setMobileFunctionPanelRequest((value) => value + 1);
return;
}
formulaEditorManagerService.handleFxBtnClick(true);
}
@@ -333,6 +342,7 @@ function MobileFormulaBarEditor() {
return (
<div
ref={formulaBarRef}
data-u-comp="mobile-formula-bar"
data-expanded={expanded}
className={clsx(`
@@ -340,7 +350,7 @@ function MobileFormulaBarEditor() {
dark:!univer-bg-gray-800
`, expanded ? 'univer-fixed univer-top-0 univer-z-50' : 'univer-absolute')}
style={{
bottom: keyboardInset,
bottom: keyboardLayout?.bottom ?? 0,
paddingBottom: expanded ? undefined : 'env(safe-area-inset-bottom, 0px)',
paddingTop: expanded ? 'env(safe-area-inset-top, 0px)' : undefined,
}}
@@ -81,7 +81,10 @@ export function MobileFormulaBarOverlays(props: IMobileFormulaBarOverlayProps) {
dark:!univer-text-gray-100
dark:active:!univer-bg-primary-900
"
onPointerDown={(event) => event.stopPropagation()}
onPointerDown={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onClick={() => props.onOperator(operator.value)}
>
{operator.label}
@@ -101,9 +104,24 @@ export function MobileFormulaBarOverlays(props: IMobileFormulaBarOverlayProps) {
export function MobileFormulaBarActions(props: IMobileFormulaBarActionProps) {
const actions = [
{ label: props.cancelLabel, icon: <CloseIcon />, onClick: props.onCancel, color: 'univer-text-red-600 dark:!univer-text-red-400' },
{ label: props.confirmLabel, icon: <CheckMarkIcon />, onClick: props.onConfirm, color: 'univer-text-green-600 dark:!univer-text-green-400' },
{ label: props.formulaLabel, icon: <FxIcon />, onClick: props.onFormula, color: 'univer-text-gray-700 dark:!univer-text-gray-100' },
{
label: props.cancelLabel,
icon: <CloseIcon />,
onClick: props.onCancel,
color: 'univer-text-red-600 dark:!univer-text-red-400',
},
{
label: props.confirmLabel,
icon: <CheckMarkIcon />,
onClick: props.onConfirm,
color: 'univer-text-green-600 dark:!univer-text-green-400',
},
{
label: props.formulaLabel,
icon: <FxIcon />,
onClick: props.onFormula,
color: 'univer-text-gray-700 dark:!univer-text-gray-100',
},
];
if (props.expanded) {
@@ -142,6 +160,7 @@ export function MobileFormulaBarActions(props: IMobileFormulaBarActionProps) {
active:univer-scale-95 active:univer-bg-gray-100
dark:active:!univer-bg-gray-700
`, action.color)}
onPointerDown={(event) => event.preventDefault()}
onClick={action.onClick}
>
{action.icon}
@@ -16,6 +16,7 @@
import type { ICommandInfo, Workbook } from '@univerjs/core';
import type { MouseEvent } from 'react';
import type { IUniverSheetsUIConfig } from '../../../config/config';
import type { LocaleKey } from '../../../locale/types';
import type { IBaseSheetBarProps } from '../../sheet-bar/sheet-bar-tabs/SheetBarItem';
import { ICommandService, LocaleService, nameCharacterCheck } from '@univerjs/core';
@@ -42,8 +43,14 @@ import {
SetWorksheetOrderMutation,
SetWorksheetShowCommand,
} from '@univerjs/sheets';
import { ContextMenuPosition, IContextMenuService, useDependency } from '@univerjs/ui';
import {
ContextMenuPosition,
IContextMenuService,
useConfigValue,
useDependency,
} from '@univerjs/ui';
import { useCallback, useEffect, useRef, useState } from 'react';
import { SHEETS_UI_PLUGIN_CONFIG_KEY } from '../../../config/config';
import { ISheetBarService } from '../../../services/sheet-bar/sheet-bar.service';
import { useActiveWorkbook } from '../../hook';
@@ -72,6 +79,8 @@ function MobileSheetBarImpl(props: { workbook: Workbook }) {
const contextMenuService = useDependency(IContextMenuService);
const localeService = useDependency(LocaleService);
const sheetBarService = useDependency(ISheetBarService);
const config = useConfigValue<IUniverSheetsUIConfig>(SHEETS_UI_PLUGIN_CONFIG_KEY);
const addSheetButtonShow = (config?.footer || {}).addSheetButtonConfig?.show ?? true;
const updateSheetItems = useCallback(() => {
const currentSubUnitId = workbook.getActiveSheet()!.getSheetId();
@@ -276,20 +285,22 @@ function MobileSheetBarImpl(props: { workbook: Workbook }) {
))}
</div>
</div>
<button
type="button"
aria-label={localeService.t<LocaleKey>('sheets-ui.mobile.addSheet')}
className={clsx(resetButtonClassName, borderLeftClassName, `
univer-flex univer-h-10 univer-w-12 univer-shrink-0 univer-items-center univer-justify-center
univer-bg-gray-100 univer-text-lg univer-text-gray-800
active:univer-bg-gray-200
dark:!univer-bg-gray-800 dark:!univer-text-gray-100
dark:active:!univer-bg-gray-700
`)}
onClick={addSheet}
>
<IncreaseIcon />
</button>
{addSheetButtonShow && (
<button
type="button"
aria-label={localeService.t<LocaleKey>('sheets-ui.mobile.addSheet')}
className={clsx(resetButtonClassName, borderLeftClassName, `
univer-flex univer-h-10 univer-w-12 univer-shrink-0 univer-items-center univer-justify-center
univer-bg-gray-100 univer-text-lg univer-text-gray-800
active:univer-bg-gray-200
dark:!univer-bg-gray-800 dark:!univer-text-gray-100
dark:active:!univer-bg-gray-700
`)}
onClick={addSheet}
>
<IncreaseIcon />
</button>
)}
</div>
<MobileDialog
open={Boolean(renameSheetId)}
@@ -38,6 +38,7 @@ export const IMenuManagerService = createIdentifier<IMenuManagerService>('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,
};
@@ -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;
@@ -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,
@@ -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 && <Button onClick={options.onCancel}>{locale?.Confirm.cancel}</Button>}
{options.showOk && <Button variant="primary" onClick={options.onOk}>{locale?.Confirm.confirm}</Button>}
</>
)
: null);
const close = () => {
dialogService.close(options.id);
@@ -147,7 +156,7 @@ export function MobileDialogPart() {
)}
</header>
)}
footer={options.footer
footer={footer
? (
<footer
className="
@@ -156,7 +165,7 @@ export function MobileDialogPart() {
dark:!univer-border-gray-700
"
>
<MobileActionRowGroup>{options.footer}</MobileActionRowGroup>
<MobileActionRowGroup>{footer}</MobileActionRowGroup>
</footer>
)
: undefined}
@@ -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(
<RediProvider value={{ injector }}>
<MobileDrawerCoordinatorProvider>{element}</MobileDrawerCoordinatorProvider>
</RediProvider>
<ConfigProvider
locale={designEnUS.design}
mountContainer={document.body}
mobileKeyboardViewport={keyboardVisible
? { top: 0, bottom: 448, height: 448, stableHeight: 768 }
: null}
>
<RediProvider value={{ injector }}>
<MobileDrawerCoordinatorProvider>{element}</MobileDrawerCoordinatorProvider>
</RediProvider>
</ConfigProvider>
),
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(<MobileDialogPart />, false, true);
const dialogService = rendered.injector.get(IDialogService);
act(() => {
dialogService.open({ id: 'keyboard-aware', children: { title: <input aria-label="Value" /> } });
});
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(<MobileDialogPart />);
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(<MobileDialogPart />);
const dialogService = rendered.injector.get(IDialogService);
const onCancel = vi.fn();
const onOk = vi.fn();
act(() => {
dialogService.open({
id: 'legacy-confirm',
children: { title: <span>Confirm action</span> },
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(
<>
@@ -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<HTMLButtonElement>) {
const touch = event.touches[0];
if (touch) {
beginDrag(touch.clientY);
}
}
function handleTouchMove(event: TouchEvent<HTMLButtonElement>) {
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}
</div>
@@ -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(
<ConfigProvider
mountContainer={document.body}
mobileKeyboardViewport={{ top: 0, bottom: 448, height: 448, stableHeight: 768 }}
>
<MobileDrawer
snap="compact"
expandLabel="Expand drawer"
collapseLabel="Collapse drawer"
onSnapChange={vi.fn()}
onClose={vi.fn()}
floatingActions={<button type="button">Floating action</button>}
>
Drawer content
</MobileDrawer>
</ConfigProvider>
);
const drawer = container.querySelector('section');
expect(drawer?.style.bottom).toBe('');
expect(drawer?.style.height).toBe('40vh');
expect(drawer?.style.maxHeight).toBe('');
expect(drawer?.querySelector<HTMLElement>('.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();
@@ -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 (
<MobileQuickSchemaGroup
key={schema.key}
@@ -188,6 +188,31 @@ describe('MobileMenu', () => {
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([{
@@ -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<HTMLDivElement>(null);
const viewportRef = useRef<HTMLDivElement>(null);
const [keyboardInset, setKeyboardInset] = useState(0);
const [keyboardViewport, setKeyboardViewport] = useState<IMobileKeyboardViewport | null>(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}
>
<MobileCanvasLayoutProvider>
<MobileKeyboardInsetContext.Provider value={keyboardInset}>
@@ -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 <div data-testid="keyboard-bar" style={{ bottom }} />;
const { mobileKeyboardViewport } = useContext(ConfigContext);
return (
<div
data-testid="keyboard-bar"
data-viewport={JSON.stringify(mobileKeyboardViewport)}
style={{ bottom }}
/>
);
}
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;