mirror of
https://github.com/dream-num/univer.git
synced 2026-10-02 04:24:34 +08:00
feat: improve mobile sheet interactions (#7702)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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!',
|
||||
}));
|
||||
|
||||
@@ -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],
|
||||
]);
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
+13
-1
@@ -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();
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
+85
-58
@@ -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,
|
||||
|
||||
+403
@@ -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;
|
||||
});
|
||||
}
|
||||
+8
-262
@@ -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(
|
||||
|
||||
+122
@@ -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();
|
||||
});
|
||||
});
|
||||
+159
@@ -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();
|
||||
});
|
||||
});
|
||||
+107
@@ -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);
|
||||
}
|
||||
}
|
||||
+84
-6
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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],
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user