From 9f65fd63e75943fb0f1a88487dc5a6de97ede5f5 Mon Sep 17 00:00:00 2001 From: Univer <68851825+DR-Univer@users.noreply.github.com> Date: Sun, 30 Aug 2026 00:03:26 +0800 Subject: [PATCH] feat(sheets-ui): improve mobile editing interactions (#7605) --- .../shared/tailwind/__tests__/content.spec.ts | 6 +- common/shared/tailwind/tailwind.config.ts | 3 + .../src/components/action-row/ActionRow.tsx | 37 + .../action-row/__tests__/ActionRow.spec.tsx | 37 + .../components/color-picker/ColorPicker.tsx | 59 +- .../color-picker/ColorPickerPanel.tsx | 66 + .../color-picker/MobileColorPresets.tsx | 61 + .../__tests__/color-picker-extra.spec.tsx | 23 +- .../color-picker/__tests__/index.spec.tsx | 87 +- .../config-provider/ConfigProvider.tsx | 15 +- .../design/src/components/dialog/Dialog.tsx | 9 +- .../src/components/dialog/DialogPrimitive.tsx | 2 +- .../components/dialog/MobileDialogContent.tsx | 51 + .../__tests__/MobileDialogContent.spec.tsx | 39 + .../components/dropdown-menu/DropdownMenu.tsx | 20 +- .../dropdown-menu/MobileDropdownMenu.tsx | 148 ++ .../dropdown-menu/__tests__/index.spec.tsx | 20 + .../src/components/dropdown/Dropdown.tsx | 18 +- .../dropdown/MobileDropdownSurface.tsx | 80 + .../dropdown/__tests__/index.spec.tsx | 30 +- .../mobile-action-row/MobileActionRow.tsx | 84 + .../__tests__/MobileActionRow.spec.tsx | 32 + .../design/src/components/tooltip/Tooltip.tsx | 8 +- .../tooltip/__tests__/index.spec.tsx | 21 + packages/design/src/index.ts | 8 + packages/design/src/locale/ar-SA.ts | 1 + packages/design/src/locale/ca-ES.ts | 1 + packages/design/src/locale/de-DE.ts | 1 + packages/design/src/locale/en-US.ts | 1 + packages/design/src/locale/es-ES.ts | 1 + packages/design/src/locale/fa-IR.ts | 1 + packages/design/src/locale/fr-FR.ts | 1 + packages/design/src/locale/id-ID.ts | 1 + packages/design/src/locale/it-IT.ts | 1 + packages/design/src/locale/ja-JP.ts | 1 + packages/design/src/locale/ko-KR.ts | 1 + packages/design/src/locale/pl-PL.ts | 1 + packages/design/src/locale/pt-BR.ts | 1 + packages/design/src/locale/ru-RU.ts | 1 + packages/design/src/locale/sk-SK.ts | 1 + packages/design/src/locale/vi-VN.ts | 1 + packages/design/src/locale/zh-CN.ts | 1 + packages/design/src/locale/zh-HK.ts | 1 + packages/design/src/locale/zh-TW.ts | 1 + packages/docs-ui/src/index.ts | 2 + packages/docs-ui/src/views/RichTextEditor.tsx | 5 +- .../MobileRichTextToolbar.tsx | 263 +++ .../hooks/__tests__/use-editor.spec.ts | 45 +- .../rich-text-editor/hooks/use-editor.ts | 8 +- .../__tests__/DrawingPanelActions.spec.tsx | 11 +- .../views/image-popup-menu/ImagePopupMenu.tsx | 25 +- .../image-popup-menu/MobileImagePopupMenu.tsx | 45 + .../src/mobile-plugin.ts | 42 +- .../ConditionFormattingPanel.spec.tsx | 18 +- .../rule-edit/__tests__/RuleEditors.spec.tsx | 19 +- .../src/menu/__tests__/dv.menu.spec.ts | 28 + .../src/menu/dv.menu.ts | 1 + .../views/components/DataValidationDetail.tsx | 6 +- .../views/components/DataValidationList.tsx | 9 +- .../__tests__/DataValidationDetail.spec.tsx | 18 +- .../drawing-popup-menu.controller.spec.ts | 20 +- .../src/menu/drawing-popup-menu.controller.ts | 50 +- .../src/controllers/mobile-menu.controller.ts | 32 + .../sheets-filter-ui/src/mobile-plugin.ts | 3 + .../sheets-formula-ui/src/locale/ar-SA.ts | 9 + .../sheets-formula-ui/src/locale/ca-ES.ts | 9 + .../sheets-formula-ui/src/locale/de-DE.ts | 9 + .../sheets-formula-ui/src/locale/en-US.ts | 9 + .../sheets-formula-ui/src/locale/es-ES.ts | 9 + .../sheets-formula-ui/src/locale/fa-IR.ts | 9 + .../sheets-formula-ui/src/locale/fr-FR.ts | 9 + .../sheets-formula-ui/src/locale/id-ID.ts | 9 + .../sheets-formula-ui/src/locale/it-IT.ts | 9 + .../sheets-formula-ui/src/locale/ja-JP.ts | 9 + .../sheets-formula-ui/src/locale/ko-KR.ts | 9 + .../sheets-formula-ui/src/locale/pl-PL.ts | 9 + .../sheets-formula-ui/src/locale/pt-BR.ts | 9 + .../sheets-formula-ui/src/locale/ru-RU.ts | 9 + .../sheets-formula-ui/src/locale/sk-SK.ts | 9 + .../sheets-formula-ui/src/locale/vi-VN.ts | 9 + .../sheets-formula-ui/src/locale/zh-CN.ts | 9 + .../sheets-formula-ui/src/locale/zh-HK.ts | 9 + .../sheets-formula-ui/src/locale/zh-TW.ts | 9 + .../formula-selection-business.spec.ts | 45 + .../hooks/use-formula-selection.ts | 25 +- .../hooks/use-left-and-right-arrow.ts | 6 +- .../hooks/use-refactor-effect.ts | 16 +- .../hooks/use-sheet-selection-change.ts | 19 +- .../src/views/formula-editor/index.tsx | 152 +- .../MobileFunctionPanel.tsx | 344 ++++ .../search-function/SearchFunction.tsx | 13 +- .../MobileRangeSelectorDialog.tsx | 90 + .../__tests__/GlobalRangeSelector.spec.tsx | 19 +- .../__tests__/RangeSelectorDialog.spec.tsx | 61 + .../src/views/range-selector/index.tsx | 135 +- .../services/__tests__/popup.service.spec.ts | 20 +- .../src/services/popup.service.ts | 150 +- .../src/views/CellLinkEdit.tsx | 27 +- .../src/views/CellLinkPopup.tsx | 64 +- .../src/views/MobileCellLinkPopup.tsx | 60 + .../views/__tests__/CellLinkPopup.spec.tsx | 18 +- .../commands/set-mobile-numfmt.command.ts | 43 + .../src/controllers/ui.controller.ts | 2 + packages/sheets-numfmt-ui/src/menu/menu.ts | 64 +- ...t-table-controls-render.controller.spec.ts | 3 +- .../sheet-table-component.controller.ts | 14 +- .../sheet-table-controls-render.controller.ts | 46 +- .../views/components/MobileSheetTableMenu.tsx | 36 + .../components/SheetTableFilterPanel.tsx | 10 +- .../src/views/components/SheetTableMenu.tsx | 9 +- .../components/SheetTableRenameDialog.tsx | 8 +- .../views/components/SheetTableSelector.tsx | 6 +- .../__tests__/SheetTableComponents.spec.tsx | 2 +- .../sheets-thread-comment-ui/src/index.ts | 1 + .../src/mobile-plugin.ts | 59 + .../commands/set-selection.command.ts | 4 +- .../sheets-ui/src/consts/mobile-context.ts | 18 + .../__tests__/data-sync.controller.spec.ts | 33 +- .../formula-editor.controller.spec.ts | 32 +- .../editor/data-sync.controller.ts | 12 +- .../editor/editing.render-controller.ts | 36 +- .../editor/formula-editor.controller.ts | 4 +- .../mobile/ui-mobile.controller.ts | 237 ++- .../editor-bridge-render-business.spec.ts | 70 +- ...bile-contextmenu.render-controller.spec.ts | 53 - .../editor-bridge.render-controller.ts | 35 + ...bile-contextmenu.render-controller.spec.ts | 175 ++ .../mobile-scroll.render-controller.spec.ts | 107 +- .../mobile-contextmenu.render-controller.ts | 229 +-- .../mobile/mobile-scroll.render-controller.ts | 37 +- packages/sheets-ui/src/locale/ar-SA.ts | 19 + packages/sheets-ui/src/locale/ca-ES.ts | 19 + packages/sheets-ui/src/locale/de-DE.ts | 19 + packages/sheets-ui/src/locale/en-US.ts | 19 + packages/sheets-ui/src/locale/es-ES.ts | 19 + packages/sheets-ui/src/locale/fa-IR.ts | 19 + packages/sheets-ui/src/locale/fr-FR.ts | 19 + packages/sheets-ui/src/locale/id-ID.ts | 19 + packages/sheets-ui/src/locale/it-IT.ts | 19 + packages/sheets-ui/src/locale/ja-JP.ts | 19 + packages/sheets-ui/src/locale/ko-KR.ts | 19 + packages/sheets-ui/src/locale/pl-PL.ts | 19 + packages/sheets-ui/src/locale/pt-BR.ts | 19 + packages/sheets-ui/src/locale/ru-RU.ts | 19 + packages/sheets-ui/src/locale/sk-SK.ts | 19 + packages/sheets-ui/src/locale/vi-VN.ts | 19 + packages/sheets-ui/src/locale/zh-CN.ts | 19 + packages/sheets-ui/src/locale/zh-HK.ts | 19 + packages/sheets-ui/src/locale/zh-TW.ts | 19 + .../__tests__/editor-bridge.service.spec.ts | 18 +- .../src/services/editor-bridge.service.ts | 17 +- ...-selection-render.service-behavior.spec.ts | 14 +- .../mobile-selection-render.service.spec.ts | 165 +- .../mobile-selection-render.service.ts | 40 +- .../src/views/border-panel/interface.ts | 34 +- .../editor-container/EditorContainer.tsx | 22 +- .../__tests__/EditorContainer.spec.tsx | 84 +- .../src/views/formula-bar/FormulaBar.tsx | 247 ++- .../formula-bar/__tests__/FormulaBar.spec.tsx | 167 +- .../action-panel/MobileSheetActionPanel.tsx | 567 +++++++ .../mobile/action-panel/MobileStylePanel.tsx | 1471 +++++++++++++++++ .../__tests__/MobileSheetActionPanel.spec.ts | 123 ++ .../__tests__/MobileStylePanel.spec.tsx | 629 +++++++ .../mobile/formula-bar/MobileFormulaBar.tsx | 113 ++ .../formula-bar/MobileFormulaBarControls.tsx | 152 ++ .../views/mobile/sheet-bar/MobileSheetBar.tsx | 298 +++- .../permission/SheetPermissionDialog.tsx | 6 +- .../permission/SheetPermissionPanelList.tsx | 6 +- .../permission/SheetPermissionUserDialog.tsx | 8 +- .../PermissionDetailFooterPart.tsx | 8 +- .../commands/utils/selection-utils.ts | 4 +- .../__tests__/selection.operation.spec.ts | 19 + .../operations/selection.operation.ts | 5 +- packages/thread-comment-ui/src/index.ts | 1 + .../thread-comment-ui/src/mobile-plugin.ts | 37 + .../src/views/ThreadCommentEditor.tsx | 8 +- .../src/views/ThreadCommentPanel.tsx | 12 +- packages/ui/src/const.ts | 2 + packages/ui/src/index.ts | 6 +- packages/ui/src/mobile-plugin.ts | 7 +- .../contextmenu/contextmenu.service.ts | 11 +- .../services/dialog/mobile-dialog.service.ts | 42 + .../utils/__tests__/storage-driver.spec.ts | 34 + packages/ui/src/utils/storage-driver.ts | 55 +- .../components/context-menu/ContextMenu.tsx | 5 +- .../context-menu/MobileContextMenu.tsx | 173 +- .../dialog-part/MobileDialogPart.tsx | 133 ++ .../__tests__/MobileDialogPart.spec.tsx | 131 ++ .../components/mobile-drawer/MobileDrawer.tsx | 228 +++ .../__tests__/MobileDrawer.spec.ts | 72 + .../views/components/ribbon/MobileRibbon.tsx | 163 +- .../ribbon/TooltipButtonWrapper.tsx | 3 +- .../ribbon/__tests__/MobileRibbon.spec.tsx | 54 + .../components/sidebar/MobileSidebar.tsx | 126 ++ .../src/views/components/sidebar/Sidebar.tsx | 61 +- .../sidebar/__tests__/Sidebar.spec.tsx | 22 + .../ui/src/views/menu/mobile/MobileMenu.tsx | 495 +++++- .../views/menu/mobile/MobileMenuDrawer.tsx | 143 ++ .../menu/mobile/__tests__/MobileMenu.spec.tsx | 473 +++++- .../mobile-workbench/MobileWorkbench.tsx | 90 +- 200 files changed, 10685 insertions(+), 933 deletions(-) create mode 100644 packages/design/src/components/action-row/ActionRow.tsx create mode 100644 packages/design/src/components/action-row/__tests__/ActionRow.spec.tsx create mode 100644 packages/design/src/components/color-picker/ColorPickerPanel.tsx create mode 100644 packages/design/src/components/color-picker/MobileColorPresets.tsx create mode 100644 packages/design/src/components/dialog/MobileDialogContent.tsx create mode 100644 packages/design/src/components/dialog/__tests__/MobileDialogContent.spec.tsx create mode 100644 packages/design/src/components/dropdown-menu/MobileDropdownMenu.tsx create mode 100644 packages/design/src/components/dropdown/MobileDropdownSurface.tsx create mode 100644 packages/design/src/components/mobile-action-row/MobileActionRow.tsx create mode 100644 packages/design/src/components/mobile-action-row/__tests__/MobileActionRow.spec.tsx create mode 100644 packages/docs-ui/src/views/mobile-rich-text-toolbar/MobileRichTextToolbar.tsx create mode 100644 packages/drawing-ui/src/views/image-popup-menu/MobileImagePopupMenu.tsx create mode 100644 packages/sheets-data-validation-ui/src/menu/__tests__/dv.menu.spec.ts create mode 100644 packages/sheets-filter-ui/src/controllers/mobile-menu.controller.ts create mode 100644 packages/sheets-formula-ui/src/views/formula-editor/mobile-function-panel/MobileFunctionPanel.tsx create mode 100644 packages/sheets-formula-ui/src/views/range-selector/MobileRangeSelectorDialog.tsx create mode 100644 packages/sheets-hyper-link-ui/src/views/MobileCellLinkPopup.tsx create mode 100644 packages/sheets-numfmt-ui/src/commands/commands/set-mobile-numfmt.command.ts create mode 100644 packages/sheets-table-ui/src/views/components/MobileSheetTableMenu.tsx create mode 100644 packages/sheets-thread-comment-ui/src/mobile-plugin.ts delete mode 100644 packages/sheets-ui/src/controllers/render-controllers/__tests__/mobile-contextmenu.render-controller.spec.ts create mode 100644 packages/sheets-ui/src/controllers/render-controllers/mobile/__tests__/mobile-contextmenu.render-controller.spec.ts create mode 100644 packages/sheets-ui/src/views/mobile/action-panel/MobileSheetActionPanel.tsx create mode 100644 packages/sheets-ui/src/views/mobile/action-panel/MobileStylePanel.tsx create mode 100644 packages/sheets-ui/src/views/mobile/action-panel/__tests__/MobileSheetActionPanel.spec.ts create mode 100644 packages/sheets-ui/src/views/mobile/action-panel/__tests__/MobileStylePanel.spec.tsx create mode 100644 packages/sheets-ui/src/views/mobile/formula-bar/MobileFormulaBar.tsx create mode 100644 packages/sheets-ui/src/views/mobile/formula-bar/MobileFormulaBarControls.tsx create mode 100644 packages/thread-comment-ui/src/mobile-plugin.ts create mode 100644 packages/ui/src/services/dialog/mobile-dialog.service.ts create mode 100644 packages/ui/src/views/components/dialog-part/MobileDialogPart.tsx create mode 100644 packages/ui/src/views/components/dialog-part/__tests__/MobileDialogPart.spec.tsx create mode 100644 packages/ui/src/views/components/mobile-drawer/MobileDrawer.tsx create mode 100644 packages/ui/src/views/components/mobile-drawer/__tests__/MobileDrawer.spec.ts create mode 100644 packages/ui/src/views/components/ribbon/__tests__/MobileRibbon.spec.tsx create mode 100644 packages/ui/src/views/components/sidebar/MobileSidebar.tsx create mode 100644 packages/ui/src/views/menu/mobile/MobileMenuDrawer.tsx diff --git a/common/shared/tailwind/__tests__/content.spec.ts b/common/shared/tailwind/__tests__/content.spec.ts index e53acfe87b..799819ad40 100644 --- a/common/shared/tailwind/__tests__/content.spec.ts +++ b/common/shared/tailwind/__tests__/content.spec.ts @@ -2,7 +2,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; -import { createTailwindContent } from '../tailwind.config'; +import tailwindConfig, { createTailwindContent } from '../tailwind.config'; const roots: string[] = []; @@ -31,6 +31,10 @@ afterEach(() => { }); describe('createTailwindContent', () => { + it('limits hover styles to devices that support hover', () => { + expect(tailwindConfig.future).toMatchObject({ hoverOnlyWhenSupported: true }); + }); + it('returns current package source content by default', () => { const packageRoot = makePackageRoot(); writeJson(path.join(packageRoot, 'package.json'), { diff --git a/common/shared/tailwind/tailwind.config.ts b/common/shared/tailwind/tailwind.config.ts index 4007792203..d7337ea15a 100644 --- a/common/shared/tailwind/tailwind.config.ts +++ b/common/shared/tailwind/tailwind.config.ts @@ -76,6 +76,9 @@ export function createTailwindContent(configUrl: string, options: ITailwindConte const config: Omit = { prefix: 'univer-', darkMode: 'selector', + future: { + hoverOnlyWhenSupported: true, + }, corePlugins: { preflight: false, }, diff --git a/packages/design/src/components/action-row/ActionRow.tsx b/packages/design/src/components/action-row/ActionRow.tsx new file mode 100644 index 0000000000..4d6948f18e --- /dev/null +++ b/packages/design/src/components/action-row/ActionRow.tsx @@ -0,0 +1,37 @@ +/** + * 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 { HTMLAttributes } from 'react'; +import { useContext } from 'react'; +import { clsx } from '../../helper/clsx'; +import { ConfigContext } from '../config-provider/ConfigProvider'; + +export type IActionRowProps = HTMLAttributes; + +export function ActionRow({ className, ...props }: IActionRowProps) { + const { mobile } = useContext(ConfigContext); + + return ( +
button]:!univer-m-0 [&>button]:!univer-h-12 [&>button]:!univer-min-w-0 [&>button]:!univer-flex-1 + [&>button]:!univer-rounded-xl + `)} + {...props} + /> + ); +} diff --git a/packages/design/src/components/action-row/__tests__/ActionRow.spec.tsx b/packages/design/src/components/action-row/__tests__/ActionRow.spec.tsx new file mode 100644 index 0000000000..ae090b6dfe --- /dev/null +++ b/packages/design/src/components/action-row/__tests__/ActionRow.spec.tsx @@ -0,0 +1,37 @@ +/** + * 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 { cleanup, render } from '@testing-library/react'; +import { afterEach, describe, expect, it } from 'vitest'; +import { ConfigProvider } from '../../config-provider/ConfigProvider'; +import { ActionRow } from '../ActionRow'; + +afterEach(cleanup); + +describe('ActionRow', () => { + it('applies touch-first button layout only in mobile presentation', () => { + const desktop = render(); + expect(desktop.container.firstElementChild?.className).not.toContain('[&>button]:!univer-h-12'); + desktop.unmount(); + + const mobile = render( + + + + ); + expect(mobile.container.firstElementChild?.className).toContain('[&>button]:!univer-h-12'); + }); +}); diff --git a/packages/design/src/components/color-picker/ColorPicker.tsx b/packages/design/src/components/color-picker/ColorPicker.tsx index 1666eda49a..191ab3adb0 100644 --- a/packages/design/src/components/color-picker/ColorPicker.tsx +++ b/packages/design/src/components/color-picker/ColorPicker.tsx @@ -15,6 +15,7 @@ */ import { memo, useCallback, useContext, useEffect, useState } from 'react'; +import { clsx } from '../../helper/clsx'; import { isBrowser } from '../../helper/is-browser'; import { Button } from '../button/Button'; import { ConfigContext } from '../config-provider/ConfigProvider'; @@ -25,12 +26,14 @@ import { ColorInput } from './ColorInput'; import { ColorPresets } from './ColorPresets'; import { ColorSpectrum } from './ColorSpectrum'; import { HueSlider } from './HueSlider'; +import { MobileColorPresets } from './MobileColorPresets'; const MemoizedColorSpectrum = memo(ColorSpectrum); const MemoizedHueSlider = memo(HueSlider); const MemoizedAlphaSlider = memo(AlphaSlider); const MemoizedColorInput = memo(ColorInput); const MemoizedColorPresets = memo(ColorPresets); +const MemoizedMobileColorPresets = memo(MobileColorPresets); export interface IColorPickerProps { format?: 'hex' | 'rgba'; @@ -39,7 +42,7 @@ export interface IColorPickerProps { } export function ColorPicker({ format = 'hex', value, onChange }: IColorPickerProps) { - const { direction, locale } = useContext(ConfigContext); + const { direction, locale, mobile } = useContext(ConfigContext); const [hsv, setHsv] = useState<[number, number, number]>([0, 100, 100]); const [alpha, setAlpha] = useState(1); @@ -107,26 +110,50 @@ export function ColorPicker({ format = 'hex', value, onChange }: IColorPickerPro className="univer-cursor-default univer-space-y-2 univer-rounded-lg" onClick={(e) => e.stopPropagation()} > - { - handleColorChange(h, s, v); - handleAlphaChange(1); - handleColorChanged(h, s, v, 1); - }} - /> + {mobile + ? ( + { + const [h, s, v] = hexToHsv(color); + handleColorChange(h, s, v); + handleAlphaChange(1); + handleColorChanged(h, s, v, 1); + }} + /> + ) + : ( + { + handleColorChange(h, s, v); + handleAlphaChange(1); + handleColorChanged(h, s, v, 1); + }} + /> + )} - void; +} + +export function ColorPickerPanel({ value = '#000000', confirmText, onConfirm }: IColorPickerPanelProps) { + const { locale } = useContext(ConfigContext); + const [hsv, setHsv] = useState<[number, number, number]>(() => hexToHsv(value)); + + const color = hsvToHex(...hsv); + + return ( +
+
+ setHsv([h, s, v])} /> +
+
+ +
+ setHsv([h, s, v])} /> +
+
+ setHsv([h, s, v])} + /> + +
+ ); +} diff --git a/packages/design/src/components/color-picker/MobileColorPresets.tsx b/packages/design/src/components/color-picker/MobileColorPresets.tsx new file mode 100644 index 0000000000..9c158c8042 --- /dev/null +++ b/packages/design/src/components/color-picker/MobileColorPresets.tsx @@ -0,0 +1,61 @@ +/** + * 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 { clsx } from '../../helper/clsx'; +import { colorPresets } from './presets'; + +export interface IMobileColorPresetsProps { + value?: string; + onSelect: (color: string) => void; +} + +export function MobileColorPresets({ value = '', onSelect }: IMobileColorPresetsProps) { + return ( +
+ {colorPresets.flat().map((color) => ( + + ))} +
+ ); +} diff --git a/packages/design/src/components/color-picker/__tests__/color-picker-extra.spec.tsx b/packages/design/src/components/color-picker/__tests__/color-picker-extra.spec.tsx index 86bfec1f8a..61d01c2cdc 100644 --- a/packages/design/src/components/color-picker/__tests__/color-picker-extra.spec.tsx +++ b/packages/design/src/components/color-picker/__tests__/color-picker-extra.spec.tsx @@ -24,6 +24,14 @@ afterEach(() => { cleanup(); }); +function getMoreColorButton(container: HTMLElement): HTMLButtonElement { + const button = Array.from( + container.querySelectorAll('[data-u-comp="color-picker"] button') + ).at(-1); + if (!button) throw new Error('More color button was not rendered.'); + return button; +} + describe('ColorPicker extra', () => { it('should handle rgba mode and confirm custom color', () => { const onChange = vi.fn(); @@ -31,9 +39,8 @@ describe('ColorPicker extra', () => { ); - const moreLink = container.querySelector('[data-u-comp="color-picker"] a') as HTMLAnchorElement; - expect(moreLink).toBeTruthy(); - fireEvent.click(moreLink); + const moreButton = getMoreColorButton(container); + fireEvent.click(moreButton); const buttons = Array.from(document.querySelectorAll('button')); const confirmBtn = buttons[buttons.length - 1] as HTMLButtonElement; @@ -71,15 +78,15 @@ describe('ColorPicker extra', () => { fireEvent.click(presetButton); expect(onChange.mock.calls.some(([value]) => String(value).startsWith('rgba('))).toBe(true); - const moreLink = container.querySelector('[data-u-comp="color-picker"] a') as HTMLAnchorElement; - fireEvent.click(moreLink); + const moreButton = getMoreColorButton(container); + fireEvent.click(moreButton); const alphaInput = document.querySelector('input[maxlength="4"]') as HTMLInputElement; fireEvent.change(alphaInput, { target: { value: '0.6' } }); const [cancelBtn, confirmBtn] = Array.from(document.querySelectorAll('footer button')) as HTMLButtonElement[]; fireEvent.click(cancelBtn); - fireEvent.click(moreLink); + fireEvent.click(moreButton); fireEvent.click(confirmBtn); expect(onChange).toHaveBeenCalled(); }); @@ -87,9 +94,9 @@ describe('ColorPicker extra', () => { it('should confirm custom color in hex mode', () => { const onChange = vi.fn(); const { container } = render(); - const moreLink = container.querySelector('[data-u-comp="color-picker"] a') as HTMLAnchorElement; + const moreButton = getMoreColorButton(container); - fireEvent.click(moreLink); + fireEvent.click(moreButton); const confirmBtn = Array.from(document.querySelectorAll('footer button')).at(-1) as HTMLButtonElement; fireEvent.click(confirmBtn); diff --git a/packages/design/src/components/color-picker/__tests__/index.spec.tsx b/packages/design/src/components/color-picker/__tests__/index.spec.tsx index 9d02585417..703402dbf8 100644 --- a/packages/design/src/components/color-picker/__tests__/index.spec.tsx +++ b/packages/design/src/components/color-picker/__tests__/index.spec.tsx @@ -22,13 +22,23 @@ import enUS from '../../../locale/en-US'; import { ConfigProvider } from '../../config-provider/ConfigProvider'; import { ColorInput } from '../ColorInput'; import { ColorPicker } from '../ColorPicker'; +import { ColorPickerPanel } from '../ColorPickerPanel'; import { ColorSpectrum } from '../ColorSpectrum'; import { HueSlider } from '../HueSlider'; +import { MobileColorPresets } from '../MobileColorPresets'; import { colorPresets } from '../presets'; import '@testing-library/jest-dom/vitest'; afterEach(cleanup); +function getMoreColorButton(container: HTMLElement): HTMLButtonElement { + const button = Array.from( + container.querySelectorAll('[data-u-comp="color-picker"] button') + ).at(-1); + if (!button) throw new Error('More color button was not rendered.'); + return button; +} + describe('ColorPicker', () => { it('should not contain duplicate preset colors', () => { const flattenedPresets = colorPresets.flat().map((color) => color.toUpperCase()); @@ -82,18 +92,22 @@ describe('ColorPicker', () => { ); - const moreLink = Array.from(container.querySelectorAll('a')).find((a) => a.textContent?.includes('更多') || a.textContent?.toLowerCase().includes('more')); - if (moreLink) { - fireEvent.click(moreLink); - expect(document.body.innerHTML).toContain('univer-grid univer-w-64 univer-gap-2'); - } + fireEvent.click(getMoreColorButton(container)); + expect(document.body.innerHTML).toContain('univer-grid univer-w-64 univer-gap-2'); }); it('should place custom color dialog above parent popovers', () => { - const { container } = render(); - const moreLink = container.querySelector('[data-u-comp="color-picker"] a') as HTMLAnchorElement; + const { container } = render( + + + + ); + const moreButton = Array.from(container.querySelectorAll('[data-u-comp="color-picker"] button')) + .find((button) => button.textContent === enUS.design.ColorPicker.more); - fireEvent.click(moreLink); + if (!moreButton) throw new Error('Custom color button was not rendered.'); + + fireEvent.click(moreButton); const dialog = document.querySelector('[role="dialog"]'); const overlay = document.querySelector('[data-state="open"].univer-fixed.univer-inset-0'); @@ -105,27 +119,52 @@ describe('ColorPicker', () => { it('should call onChange when rgb input changes in dialog', () => { const handleChange = vi.fn(); const { container } = render(); - const moreLink = Array.from(container.querySelectorAll('a')).find((a) => a.textContent?.includes('更多') || a.textContent?.toLowerCase().includes('more')); - if (moreLink) { - moreLink.dispatchEvent(new MouseEvent('click', { bubbles: true })); - const rgbInputs = Array.from(document.querySelectorAll('input')).filter((input) => input.maxLength === 3) as HTMLInputElement[]; - if (rgbInputs.length === 3) { - rgbInputs[0].value = '1'; - rgbInputs[0].dispatchEvent(new Event('input', { bubbles: true })); - rgbInputs[1].value = '2'; - rgbInputs[1].dispatchEvent(new Event('input', { bubbles: true })); - rgbInputs[2].value = '3'; - rgbInputs[2].dispatchEvent(new Event('input', { bubbles: true })); - const confirmBtn = Array.from(document.querySelectorAll('button')).find((btn) => btn.textContent?.includes('确定') || btn.textContent?.toLowerCase().includes('confirm')); - if (confirmBtn) { - confirmBtn.dispatchEvent(new MouseEvent('click', { bubbles: true })); - expect(handleChange).toHaveBeenCalled(); - } + getMoreColorButton(container).dispatchEvent(new MouseEvent('click', { bubbles: true })); + const rgbInputs = Array.from(document.querySelectorAll('input')).filter((input) => input.maxLength === 3); + if (rgbInputs.length === 3) { + rgbInputs[0].value = '1'; + rgbInputs[0].dispatchEvent(new Event('input', { bubbles: true })); + rgbInputs[1].value = '2'; + rgbInputs[1].dispatchEvent(new Event('input', { bubbles: true })); + rgbInputs[2].value = '3'; + rgbInputs[2].dispatchEvent(new Event('input', { bubbles: true })); + const confirmBtn = Array.from(document.querySelectorAll('button')).find((btn) => btn.textContent?.includes('确定') || btn.textContent?.toLowerCase().includes('confirm')); + if (confirmBtn) { + confirmBtn.dispatchEvent(new MouseEvent('click', { bubbles: true })); + expect(handleChange).toHaveBeenCalled(); } } }); }); +describe('mobile color picker views', () => { + it('renders large preset targets and applies the selected color', () => { + const onSelect = vi.fn(); + const { container } = render(); + const buttons = container.querySelectorAll('button'); + + expect(buttons).toHaveLength(colorPresets.flat().length); + expect(buttons[0]).toHaveAttribute('aria-pressed', 'true'); + expect(buttons[0].querySelector('span')).toHaveClass('univer-aspect-square', 'univer-w-8'); + + fireEvent.click(buttons[1]); + expect(onSelect).toHaveBeenCalledWith(colorPresets.flat()[1]); + }); + + it('commits a custom color only when the apply button is clicked', () => { + const onConfirm = vi.fn(); + const { getByRole } = render( + + + + ); + + expect(onConfirm).not.toHaveBeenCalled(); + fireEvent.click(getByRole('button', { name: enUS.design.ColorPicker.confirm })); + expect(onConfirm).toHaveBeenCalledWith('#3f83f8'); + }); +}); + describe('HueSlider', () => { const onChange = vi.fn(); diff --git a/packages/design/src/components/config-provider/ConfigProvider.tsx b/packages/design/src/components/config-provider/ConfigProvider.tsx index faf4059077..16c75906f5 100644 --- a/packages/design/src/components/config-provider/ConfigProvider.tsx +++ b/packages/design/src/components/config-provider/ConfigProvider.tsx @@ -16,7 +16,7 @@ import type { ReactNode } from 'react'; import { DirectionProvider } from '@radix-ui/react-direction'; -import { createContext, useMemo } from 'react'; +import { createContext, useContext, useMemo } from 'react'; import { isBrowser } from '../../helper/is-browser'; export interface IConfigProviderProps { @@ -24,6 +24,10 @@ export interface IConfigProviderProps { locale?: any; direction?: 'ltr' | 'rtl'; mountContainer: HTMLElement | null; + /** Disable tooltip popovers for this provider subtree. */ + disableTooltips?: boolean; + /** Use touch-first presentation for overlays in this provider subtree. */ + mobile?: boolean; } export const ConfigContext = createContext>({ @@ -31,15 +35,20 @@ export const ConfigContext = createContext { return { locale, direction, mountContainer, + disableTooltips: resolvedDisableTooltips, + mobile: resolvedMobile, }; - }, [locale, direction, mountContainer]); + }, [locale, direction, mountContainer, resolvedDisableTooltips, resolvedMobile]); return ( diff --git a/packages/design/src/components/dialog/Dialog.tsx b/packages/design/src/components/dialog/Dialog.tsx index ce45fe4510..e6a4f11d9f 100644 --- a/packages/design/src/components/dialog/Dialog.tsx +++ b/packages/design/src/components/dialog/Dialog.tsx @@ -27,6 +27,7 @@ import { Dialog as DialogProvider, DialogTitle, } from './DialogPrimitive'; +import { MobileDialogContent } from './MobileDialogContent'; export interface IDialogProps { children: ReactNode; @@ -270,7 +271,7 @@ export function Dialog(props: IDialogProps) { onCancel, } = props; - const { locale, mountContainer, direction } = useContext(ConfigContext); + const { locale, mountContainer, direction, mobile } = useContext(ConfigContext); const { position, isDragging, setElementRef, handleMouseDown } = useDraggable({ defaultPosition, enabled: draggable }); @@ -314,13 +315,15 @@ export function Dialog(props: IDialogProps) { onClose?.(); } + const Content = mobile ? MobileDialogContent : DialogContent; + return ( - )} - + ); } diff --git a/packages/design/src/components/dialog/DialogPrimitive.tsx b/packages/design/src/components/dialog/DialogPrimitive.tsx index ebe3f0fd37..b4655e96fb 100644 --- a/packages/design/src/components/dialog/DialogPrimitive.tsx +++ b/packages/design/src/components/dialog/DialogPrimitive.tsx @@ -48,7 +48,7 @@ const DialogOverlay = forwardRef< )); DialogOverlay.displayName = Overlay.displayName; -interface IDialogContentProps { +export interface IDialogContentProps { closable?: boolean; onClickClose?: () => void; mountContainer?: HTMLElement | null; diff --git a/packages/design/src/components/dialog/MobileDialogContent.tsx b/packages/design/src/components/dialog/MobileDialogContent.tsx new file mode 100644 index 0000000000..82ec9b5da6 --- /dev/null +++ b/packages/design/src/components/dialog/MobileDialogContent.tsx @@ -0,0 +1,51 @@ +/** + * 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 { ComponentProps, ComponentRef } from 'react'; +import { forwardRef } from 'react'; +import { clsx } from '../../helper/clsx'; +import { DialogContent } from './DialogPrimitive'; + +export const MobileDialogContent = forwardRef, ComponentProps>((props, ref) => { + const { className, style, ...rest } = props; + + return ( + + ); +}); diff --git a/packages/design/src/components/dialog/__tests__/MobileDialogContent.spec.tsx b/packages/design/src/components/dialog/__tests__/MobileDialogContent.spec.tsx new file mode 100644 index 0000000000..9404297cc0 --- /dev/null +++ b/packages/design/src/components/dialog/__tests__/MobileDialogContent.spec.tsx @@ -0,0 +1,39 @@ +/** + * Copyright 2023-present DreamNum Co., Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { cleanup, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it } from 'vitest'; +import { Dialog as DialogProvider, DialogTitle } from '../DialogPrimitive'; +import { MobileDialogContent } from '../MobileDialogContent'; +import '@testing-library/jest-dom/vitest'; + +afterEach(cleanup); + +describe('MobileDialogContent', () => { + it('renders a full-width bottom surface', () => { + render( + + + Mobile dialog + + + ); + + const dialog = screen.getByRole('dialog'); + expect(dialog).toHaveStyle({ bottom: '0px', position: 'fixed', width: '100%' }); + expect(dialog.className).toContain('!univer-rounded-t-2xl'); + }); +}); diff --git a/packages/design/src/components/dropdown-menu/DropdownMenu.tsx b/packages/design/src/components/dropdown-menu/DropdownMenu.tsx index d5e3597a19..b1a73f7065 100644 --- a/packages/design/src/components/dropdown-menu/DropdownMenu.tsx +++ b/packages/design/src/components/dropdown-menu/DropdownMenu.tsx @@ -15,7 +15,8 @@ */ import type { ComponentProps, ReactNode } from 'react'; -import { useState } from 'react'; +import { useContext, useState } from 'react'; +import { ConfigContext } from '../config-provider/ConfigProvider'; import { DropdownMenuCheckboxItem, DropdownMenuContent, @@ -30,6 +31,7 @@ import { DropdownMenuSubTrigger, DropdownMenuTrigger, } from './DropdownMenuPrimitive'; +import { MobileDropdownMenu } from './MobileDropdownMenu'; interface IDropdownMenuNormalItem { type: 'item'; @@ -85,7 +87,7 @@ interface IDropdownMenuCustomItem { children: ReactNode; } -type DropdownMenuType = IDropdownMenuNormalItem | IDropdownMenuNormalSubItem | IDropdownMenuSeparatorItem | IDropdownMenuRadioItem | IDropdownMenuCheckItem | IDropdownMenuCustomItem; +export type DropdownMenuType = IDropdownMenuNormalItem | IDropdownMenuNormalSubItem | IDropdownMenuSeparatorItem | IDropdownMenuRadioItem | IDropdownMenuCheckItem | IDropdownMenuCustomItem; export interface IDropdownMenuProps extends ComponentProps { children: ReactNode; @@ -106,6 +108,7 @@ export function DropdownMenu(props: IDropdownMenuProps) { } = props; const [uncontrolledOpen, setUncontrolledOpen] = useState(false); + const { mobile } = useContext(ConfigContext); const isControlled = controlledOpen !== undefined; const open = isControlled ? controlledOpen : uncontrolledOpen; @@ -207,6 +210,19 @@ export function DropdownMenu(props: IDropdownMenuProps) { } } + if (mobile) { + return ( + + {children} + + ); + } + return ( diff --git a/packages/design/src/components/dropdown-menu/MobileDropdownMenu.tsx b/packages/design/src/components/dropdown-menu/MobileDropdownMenu.tsx new file mode 100644 index 0000000000..f02776d1a7 --- /dev/null +++ b/packages/design/src/components/dropdown-menu/MobileDropdownMenu.tsx @@ -0,0 +1,148 @@ +/** + * 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 { ReactNode } from 'react'; +import type { DropdownMenuType } from './DropdownMenu'; +import { clsx } from '../../helper/clsx'; +import { MobileDropdownSurface } from '../dropdown/MobileDropdownSurface'; + +interface IMobileDropdownMenuProps { + children: ReactNode; + items: DropdownMenuType[]; + disabled?: boolean; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function MobileDropdownMenu(props: IMobileDropdownMenuProps) { + const { children, items, disabled, open, onOpenChange } = props; + + function renderMenuItem(item: DropdownMenuType, index: number): ReactNode { + if (item.type === 'separator') { + return ( +
+ ); + } + if (item.type === 'custom') { + return
{item.children}
; + } + if (item.type === 'radio') { + return item.options.map((option, optionIndex) => { + if ('type' in option) { + return renderMenuItem(option, optionIndex); + } + + const { value } = option; + if (value === undefined) { + throw new Error('[DropdownMenu]: `value` is required'); + } + + return ( + + ); + }); + } + if (item.type === 'checkbox') { + return ( + + ); + } + if (item.type === 'item') { + return ( + + ); + } + return ( +
+
+ {item.children} +
+ {item.options?.map(renderMenuItem)} +
+ ); + } + + return ( + {items.map(renderMenuItem)}
} + > + {children} + + ); +} + +function mobileRowClassName(active: boolean): string { + return clsx(` + univer-flex univer-h-12 univer-w-full univer-items-center univer-rounded-xl univer-border-0 univer-px-4 + univer-text-left univer-text-base univer-text-gray-900 univer-outline-none + active:univer-bg-gray-200 + disabled:univer-opacity-40 + dark:!univer-text-gray-0 + dark:active:!univer-bg-gray-700 + `, active + ? ` + univer-bg-primary-50 + dark:!univer-bg-gray-700 + ` + : ` + univer-bg-gray-0 + dark:!univer-bg-gray-800 + `); +} diff --git a/packages/design/src/components/dropdown-menu/__tests__/index.spec.tsx b/packages/design/src/components/dropdown-menu/__tests__/index.spec.tsx index 4a0f1bef2c..81bea6dae7 100644 --- a/packages/design/src/components/dropdown-menu/__tests__/index.spec.tsx +++ b/packages/design/src/components/dropdown-menu/__tests__/index.spec.tsx @@ -16,6 +16,7 @@ import { cleanup, fireEvent, render } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; +import { ConfigProvider } from '../../config-provider/ConfigProvider'; import { DropdownMenu } from '../DropdownMenu'; import '@testing-library/jest-dom/vitest'; @@ -189,4 +190,23 @@ describe('DropdownMenu', () => { ) ).toThrow('[DropdownMenu]: `value` is required'); }); + + it('should render full-width actionable rows under a mobile provider', () => { + const onSelect = vi.fn(); + const { getByText } = render( + + + + + + ); + + const item = getByText('Mobile item'); + expect(item).toHaveClass('univer-w-full'); + fireEvent.click(item); + expect(onSelect).toHaveBeenCalledTimes(1); + }); }); diff --git a/packages/design/src/components/dropdown/Dropdown.tsx b/packages/design/src/components/dropdown/Dropdown.tsx index ad58603bf3..b898d3af42 100644 --- a/packages/design/src/components/dropdown/Dropdown.tsx +++ b/packages/design/src/components/dropdown/Dropdown.tsx @@ -15,7 +15,9 @@ */ import type { ComponentProps, ReactNode } from 'react'; -import { useState } from 'react'; +import { useContext, useState } from 'react'; +import { ConfigContext } from '../config-provider/ConfigProvider'; +import { MobileDropdownSurface } from './MobileDropdownSurface'; import { PopoverContent, PopoverPrimitive, PopoverTrigger } from './PopoverPrimitive'; export interface IDropdownProps extends ComponentProps { @@ -37,6 +39,7 @@ export function Dropdown(props: IDropdownProps) { } = props; const [uncontrolledOpen, setUncontrolledOpen] = useState(false); + const { mobile } = useContext(ConfigContext); const isControlled = controlledOpen !== undefined; const open = isControlled ? controlledOpen : uncontrolledOpen; @@ -51,6 +54,19 @@ export function Dropdown(props: IDropdownProps) { controlledOnOpenChange?.(newOpen); } + if (mobile) { + return ( + + {children} + + ); + } + return ( diff --git a/packages/design/src/components/dropdown/MobileDropdownSurface.tsx b/packages/design/src/components/dropdown/MobileDropdownSurface.tsx new file mode 100644 index 0000000000..bef36759ca --- /dev/null +++ b/packages/design/src/components/dropdown/MobileDropdownSurface.tsx @@ -0,0 +1,80 @@ +/** + * 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 { ReactNode } from 'react'; +import { useContext } from 'react'; +import { ConfigContext } from '../config-provider/ConfigProvider'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogTitle, + DialogTrigger, +} from '../dialog/DialogPrimitive'; + +export function MobileDropdownSurface(props: { + children: ReactNode; + content: ReactNode; + open: boolean; + disabled?: boolean; + onOpenChange: (open: boolean) => void; +}) { + const { children, content, open, disabled, onOpenChange } = props; + const { locale, mountContainer } = useContext(ConfigContext); + + return ( + + + {children} + + + + ); +} diff --git a/packages/design/src/components/dropdown/__tests__/index.spec.tsx b/packages/design/src/components/dropdown/__tests__/index.spec.tsx index ec037d42eb..a8b6059bc7 100644 --- a/packages/design/src/components/dropdown/__tests__/index.spec.tsx +++ b/packages/design/src/components/dropdown/__tests__/index.spec.tsx @@ -14,10 +14,15 @@ * limitations under the License. */ -import { cleanup, render } from '@testing-library/react'; +/** + * @vitest-environment jsdom + */ + +import { cleanup, render, screen } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; +import enUS from '../../../locale/en-US'; +import { ConfigProvider } from '../../config-provider/ConfigProvider'; import { Dropdown } from '../Dropdown'; -import '@testing-library/jest-dom/vitest'; afterEach(cleanup); @@ -28,8 +33,8 @@ describe('Dropdown', () => { ); - expect(getByText('Trigger')).toBeInTheDocument(); - expect(queryByText('Overlay Content')).not.toBeInTheDocument(); + expect(getByText('Trigger')).toBeTruthy(); + expect(queryByText('Overlay Content')).toBeNull(); }); it('should show overlay when open is true', () => { @@ -38,7 +43,7 @@ describe('Dropdown', () => { ); - expect(getByText('Overlay Content')).toBeInTheDocument(); + expect(getByText('Overlay Content')).toBeTruthy(); }); it('should call onOpenChange when trigger is clicked', () => { @@ -51,4 +56,19 @@ describe('Dropdown', () => { getByText('Trigger').click(); expect(handleOpenChange).toHaveBeenCalled(); }); + + it('should render a touch-first dialog surface under a mobile provider', () => { + render( + + Overlay Content
} open> + + + + ); + + const dialog = screen.getByRole('dialog'); + expect(dialog.textContent).toContain('Overlay Content'); + expect(dialog.classList.contains('!univer-bottom-0')).toBe(true); + expect(screen.getByText(enUS.design.Accessibility.menu)).toBeTruthy(); + }); }); diff --git a/packages/design/src/components/mobile-action-row/MobileActionRow.tsx b/packages/design/src/components/mobile-action-row/MobileActionRow.tsx new file mode 100644 index 0000000000..d276357490 --- /dev/null +++ b/packages/design/src/components/mobile-action-row/MobileActionRow.tsx @@ -0,0 +1,84 @@ +/** + * 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 { ButtonHTMLAttributes, ReactNode } from 'react'; +import { borderBottomClassName, resetButtonClassName } from '../../helper/class-utilities'; +import { clsx } from '../../helper/clsx'; + +export interface IMobileActionRowProps extends Omit, 'title' | 'value'> { + title: ReactNode; + icon?: ReactNode; + value?: string; + valueType?: 'color' | 'text'; + trailing?: ReactNode; + bordered?: boolean; + variant?: 'surface' | 'subtle'; +} + +export function MobileActionRow(props: IMobileActionRowProps) { + const { + title, + icon, + value, + valueType = 'color', + trailing, + bordered, + variant = 'surface', + className, + type = 'button', + ...buttonProps + } = props; + + return ( + + ); +} diff --git a/packages/design/src/components/mobile-action-row/__tests__/MobileActionRow.spec.tsx b/packages/design/src/components/mobile-action-row/__tests__/MobileActionRow.spec.tsx new file mode 100644 index 0000000000..ba2cfb626c --- /dev/null +++ b/packages/design/src/components/mobile-action-row/__tests__/MobileActionRow.spec.tsx @@ -0,0 +1,32 @@ +/** + * 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 { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { MobileActionRow } from '../MobileActionRow'; + +describe('MobileActionRow', () => { + it('provides a full-width touch action without desktop hover behavior', () => { + const onClick = vi.fn(); + render(); + + const button = screen.getByRole('button', { name: 'Rename' }); + expect(button.className).toContain('univer-min-h-12'); + expect(button.className).not.toContain('hover:'); + fireEvent.click(button); + expect(onClick).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/design/src/components/tooltip/Tooltip.tsx b/packages/design/src/components/tooltip/Tooltip.tsx index 0116fcd4ac..35576c7082 100644 --- a/packages/design/src/components/tooltip/Tooltip.tsx +++ b/packages/design/src/components/tooltip/Tooltip.tsx @@ -72,14 +72,14 @@ export function Tooltip(props: ITooltipProps) { onVisibleChange, } = props; - const { direction } = useContext(ConfigContext); + const { direction, disableTooltips } = useContext(ConfigContext); // Internal state for uncontrolled mode const [uncontrolledVisible, setUncontrolledVisible] = useState(false); // Determine whether the tooltip is controlled or uncontrolled const isControlled = controlledVisible !== undefined; - const visible = isControlled ? controlledVisible : uncontrolledVisible; + const visible = !disableTooltips && (isControlled ? controlledVisible : uncontrolledVisible); const triggerRef = useRef(null); const tooltipRef = useRef(null); @@ -93,6 +93,8 @@ export function Tooltip(props: ITooltipProps) { } function showTooltip() { + if (disableTooltips) return; + if (isControlled) { onVisibleChange?.(true); } else { @@ -101,6 +103,8 @@ export function Tooltip(props: ITooltipProps) { } function hideTooltip() { + if (disableTooltips) return; + if (isControlled) { onVisibleChange?.(false); } else { diff --git a/packages/design/src/components/tooltip/__tests__/index.spec.tsx b/packages/design/src/components/tooltip/__tests__/index.spec.tsx index 574730c874..8b5bfd707f 100644 --- a/packages/design/src/components/tooltip/__tests__/index.spec.tsx +++ b/packages/design/src/components/tooltip/__tests__/index.spec.tsx @@ -16,6 +16,7 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ConfigProvider } from '../../config-provider/ConfigProvider'; import { Tooltip } from '../Tooltip'; import '@testing-library/jest-dom/vitest'; @@ -88,6 +89,26 @@ describe('Tooltip', () => { expect(onVisibleChange).toHaveBeenCalledWith(false); }); + it('should stay hidden when tooltips are disabled by the config provider', () => { + const onVisibleChange = vi.fn(); + render( + + + + Trigger + + + + ); + + const trigger = screen.getByText('Trigger'); + fireEvent.mouseEnter(trigger); + fireEvent.focus(trigger); + + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + expect(onVisibleChange).not.toHaveBeenCalled(); + }); + it('should support non-asChild trigger and focus/blur events', async () => { render( diff --git a/packages/design/src/index.ts b/packages/design/src/index.ts index 6673ddeb79..314b954f0e 100644 --- a/packages/design/src/index.ts +++ b/packages/design/src/index.ts @@ -18,6 +18,8 @@ import './global.css'; export { Accordion } from './components/accordion/Accordion'; export type { IAccordionProps } from './components/accordion/Accordion'; +export { ActionRow } from './components/action-row/ActionRow'; +export type { IActionRowProps } from './components/action-row/ActionRow'; export { Avatar } from './components/avatar/Avatar'; export type { IAvatarProps } from './components/avatar/Avatar'; export { Badge } from './components/badge/Badge'; @@ -35,7 +37,11 @@ export { Checkbox } from './components/checkbox/Checkbox'; export type { ICheckboxProps } from './components/checkbox/Checkbox'; export { ColorPicker } from './components/color-picker/ColorPicker'; export type { IColorPickerProps } from './components/color-picker/ColorPicker'; +export { ColorPickerPanel } from './components/color-picker/ColorPickerPanel'; +export type { IColorPickerPanelProps } from './components/color-picker/ColorPickerPanel'; export { ColorPresets } from './components/color-picker/ColorPresets'; +export { MobileColorPresets } from './components/color-picker/MobileColorPresets'; +export type { IMobileColorPresetsProps } from './components/color-picker/MobileColorPresets'; export { Command, CommandEmpty, @@ -81,6 +87,8 @@ export { KBD } from './components/kbd/Kbd'; export type { IKBDProps } from './components/kbd/Kbd'; export { message, Messager, MessageType, removeMessage } from './components/message/Message'; export type { IMessageProps } from './components/message/Message'; +export { MobileActionRow } from './components/mobile-action-row/MobileActionRow'; +export type { IMobileActionRowProps } from './components/mobile-action-row/MobileActionRow'; export { Pager } from './components/pager/Pager'; export type { IPagerProps } from './components/pager/Pager'; export { Panel, PanelField, PanelSection } from './components/panel/Panel'; diff --git a/packages/design/src/locale/ar-SA.ts b/packages/design/src/locale/ar-SA.ts index c2aba8bacb..cf0fee670d 100644 --- a/packages/design/src/locale/ar-SA.ts +++ b/packages/design/src/locale/ar-SA.ts @@ -21,6 +21,7 @@ const locale: typeof enUS = { Accessibility: { closeBadge: 'إغلاق الشارة', close: 'إغلاق', + menu: 'القائمة', previous: 'السابق', next: 'التالي', imageGallery: 'معرض الصور', diff --git a/packages/design/src/locale/ca-ES.ts b/packages/design/src/locale/ca-ES.ts index 4d5815dd75..837de71bd2 100644 --- a/packages/design/src/locale/ca-ES.ts +++ b/packages/design/src/locale/ca-ES.ts @@ -21,6 +21,7 @@ const locale: typeof enUS = { Accessibility: { closeBadge: 'Tanca la insígnia', close: 'Tanca', + menu: 'Menú', previous: 'Anterior', next: 'Següent', imageGallery: 'Galeria d’imatges', diff --git a/packages/design/src/locale/de-DE.ts b/packages/design/src/locale/de-DE.ts index 76a4550212..131b71315d 100644 --- a/packages/design/src/locale/de-DE.ts +++ b/packages/design/src/locale/de-DE.ts @@ -21,6 +21,7 @@ const locale: typeof enUS = { Accessibility: { closeBadge: 'Badge schließen', close: 'Schließen', + menu: 'Menü', previous: 'Zurück', next: 'Weiter', imageGallery: 'Bildergalerie', diff --git a/packages/design/src/locale/en-US.ts b/packages/design/src/locale/en-US.ts index 8223b0fc62..2c5b1d1d81 100644 --- a/packages/design/src/locale/en-US.ts +++ b/packages/design/src/locale/en-US.ts @@ -19,6 +19,7 @@ const locale = { Accessibility: { closeBadge: 'Close badge', close: 'Close', + menu: 'Menu', previous: 'Previous', next: 'Next', imageGallery: 'Image gallery', diff --git a/packages/design/src/locale/es-ES.ts b/packages/design/src/locale/es-ES.ts index a30cadb561..8a48bd0a12 100644 --- a/packages/design/src/locale/es-ES.ts +++ b/packages/design/src/locale/es-ES.ts @@ -21,6 +21,7 @@ const locale: typeof enUS = { Accessibility: { closeBadge: 'Cerrar insignia', close: 'Cerrar', + menu: 'Menú', previous: 'Anterior', next: 'Siguiente', imageGallery: 'Galería de imágenes', diff --git a/packages/design/src/locale/fa-IR.ts b/packages/design/src/locale/fa-IR.ts index c332fe21ac..9737314f95 100644 --- a/packages/design/src/locale/fa-IR.ts +++ b/packages/design/src/locale/fa-IR.ts @@ -21,6 +21,7 @@ const locale: typeof enUS = { Accessibility: { closeBadge: 'بستن نشان', close: 'بستن', + menu: 'منو', previous: 'قبلی', next: 'بعدی', imageGallery: 'گالری تصاویر', diff --git a/packages/design/src/locale/fr-FR.ts b/packages/design/src/locale/fr-FR.ts index 98dea0f801..486b746e6f 100644 --- a/packages/design/src/locale/fr-FR.ts +++ b/packages/design/src/locale/fr-FR.ts @@ -21,6 +21,7 @@ const locale: typeof enUS = { Accessibility: { closeBadge: 'Fermer le badge', close: 'Fermer', + menu: 'Menu', previous: 'Précédent', next: 'Suivant', imageGallery: 'Galerie d’images', diff --git a/packages/design/src/locale/id-ID.ts b/packages/design/src/locale/id-ID.ts index d70406c61c..62817dd120 100644 --- a/packages/design/src/locale/id-ID.ts +++ b/packages/design/src/locale/id-ID.ts @@ -21,6 +21,7 @@ const locale: typeof enUS = { Accessibility: { closeBadge: 'Tutup lencana', close: 'Tutup', + menu: 'Menu', previous: 'Sebelumnya', next: 'Berikutnya', imageGallery: 'Galeri gambar', diff --git a/packages/design/src/locale/it-IT.ts b/packages/design/src/locale/it-IT.ts index 5c9658c338..44df31c325 100644 --- a/packages/design/src/locale/it-IT.ts +++ b/packages/design/src/locale/it-IT.ts @@ -21,6 +21,7 @@ const locale: typeof enUS = { Accessibility: { closeBadge: 'Chiudi badge', close: 'Chiudi', + menu: 'Menu', previous: 'Precedente', next: 'Successivo', imageGallery: 'Galleria immagini', diff --git a/packages/design/src/locale/ja-JP.ts b/packages/design/src/locale/ja-JP.ts index c00183d706..bcf440b142 100644 --- a/packages/design/src/locale/ja-JP.ts +++ b/packages/design/src/locale/ja-JP.ts @@ -21,6 +21,7 @@ const locale: typeof enUS = { Accessibility: { closeBadge: 'バッジを閉じる', close: '閉じる', + menu: 'メニュー', previous: '前へ', next: '次へ', imageGallery: '画像ギャラリー', diff --git a/packages/design/src/locale/ko-KR.ts b/packages/design/src/locale/ko-KR.ts index 2c21db831e..c24b4a966a 100644 --- a/packages/design/src/locale/ko-KR.ts +++ b/packages/design/src/locale/ko-KR.ts @@ -21,6 +21,7 @@ const locale: typeof enUS = { Accessibility: { closeBadge: '배지 닫기', close: '닫기', + menu: '메뉴', previous: '이전', next: '다음', imageGallery: '이미지 갤러리', diff --git a/packages/design/src/locale/pl-PL.ts b/packages/design/src/locale/pl-PL.ts index 28ad2b61da..5dd369254a 100644 --- a/packages/design/src/locale/pl-PL.ts +++ b/packages/design/src/locale/pl-PL.ts @@ -21,6 +21,7 @@ const locale: typeof enUS = { Accessibility: { closeBadge: 'Zamknij plakietkę', close: 'Zamknij', + menu: 'Menu', previous: 'Poprzedni', next: 'Następny', imageGallery: 'Galeria obrazów', diff --git a/packages/design/src/locale/pt-BR.ts b/packages/design/src/locale/pt-BR.ts index 87fe177f71..4dce995546 100644 --- a/packages/design/src/locale/pt-BR.ts +++ b/packages/design/src/locale/pt-BR.ts @@ -21,6 +21,7 @@ const locale: typeof enUS = { Accessibility: { closeBadge: 'Fechar selo', close: 'Fechar', + menu: 'Menu', previous: 'Anterior', next: 'Próximo', imageGallery: 'Galeria de imagens', diff --git a/packages/design/src/locale/ru-RU.ts b/packages/design/src/locale/ru-RU.ts index b04b908ae5..885998f1da 100644 --- a/packages/design/src/locale/ru-RU.ts +++ b/packages/design/src/locale/ru-RU.ts @@ -21,6 +21,7 @@ const locale: typeof enUS = { Accessibility: { closeBadge: 'Закрыть метку', close: 'Закрыть', + menu: 'Меню', previous: 'Предыдущее', next: 'Следующее', imageGallery: 'Галерея изображений', diff --git a/packages/design/src/locale/sk-SK.ts b/packages/design/src/locale/sk-SK.ts index 7281b23603..f99bf92b56 100644 --- a/packages/design/src/locale/sk-SK.ts +++ b/packages/design/src/locale/sk-SK.ts @@ -21,6 +21,7 @@ const locale: typeof enUS = { Accessibility: { closeBadge: 'Zavrieť odznak', close: 'Zavrieť', + menu: 'Ponuka', previous: 'Predchádzajúci', next: 'Nasledujúci', imageGallery: 'Galéria obrázkov', diff --git a/packages/design/src/locale/vi-VN.ts b/packages/design/src/locale/vi-VN.ts index 3cb723a4d7..f68c799773 100644 --- a/packages/design/src/locale/vi-VN.ts +++ b/packages/design/src/locale/vi-VN.ts @@ -21,6 +21,7 @@ const locale: typeof enUS = { Accessibility: { closeBadge: 'Đóng huy hiệu', close: 'Đóng', + menu: 'Trình đơn', previous: 'Trước', next: 'Tiếp theo', imageGallery: 'Thư viện ảnh', diff --git a/packages/design/src/locale/zh-CN.ts b/packages/design/src/locale/zh-CN.ts index bf594acd0e..d139720015 100644 --- a/packages/design/src/locale/zh-CN.ts +++ b/packages/design/src/locale/zh-CN.ts @@ -21,6 +21,7 @@ const locale: typeof enUS = { Accessibility: { closeBadge: '关闭徽章', close: '关闭', + menu: '菜单', previous: '上一个', next: '下一个', imageGallery: '图片库', diff --git a/packages/design/src/locale/zh-HK.ts b/packages/design/src/locale/zh-HK.ts index 3bff613397..f01f534788 100644 --- a/packages/design/src/locale/zh-HK.ts +++ b/packages/design/src/locale/zh-HK.ts @@ -21,6 +21,7 @@ const locale: typeof enUS = { Accessibility: { closeBadge: '關閉徽章', close: '關閉', + menu: '選單', previous: '上一個', next: '下一個', imageGallery: '圖片庫', diff --git a/packages/design/src/locale/zh-TW.ts b/packages/design/src/locale/zh-TW.ts index 3bff613397..f01f534788 100644 --- a/packages/design/src/locale/zh-TW.ts +++ b/packages/design/src/locale/zh-TW.ts @@ -21,6 +21,7 @@ const locale: typeof enUS = { Accessibility: { closeBadge: '關閉徽章', close: '關閉', + menu: '選單', previous: '上一個', next: '下一個', imageGallery: '圖片庫', diff --git a/packages/docs-ui/src/index.ts b/packages/docs-ui/src/index.ts index e0563ac29c..5d32df8bfa 100644 --- a/packages/docs-ui/src/index.ts +++ b/packages/docs-ui/src/index.ts @@ -276,6 +276,8 @@ export { getAnchorBounding, getLineBounding, TEXT_RANGE_LAYER_INDEX, TextRange } export { whenDocAndEditorFocused } from './shortcuts/utils'; export { DOC_VERTICAL_PADDING } from './types/const/padding'; export { BulletListTypePicker, OrderListTypePicker } from './views/list-type-picker/Picker'; +export { MobileRichTextToolbar } from './views/mobile-rich-text-toolbar/MobileRichTextToolbar'; +export type { IMobileRichTextToolbarProps } from './views/mobile-rich-text-toolbar/MobileRichTextToolbar'; export { createEditorUndoRedoKeyboardConfig, executeEditorUndoRedoCommand, diff --git a/packages/docs-ui/src/views/RichTextEditor.tsx b/packages/docs-ui/src/views/RichTextEditor.tsx index 67dca4c06a..b22d5d9463 100644 --- a/packages/docs-ui/src/views/RichTextEditor.tsx +++ b/packages/docs-ui/src/views/RichTextEditor.tsx @@ -16,7 +16,7 @@ import type { IDocumentData } from '@univerjs/core'; import type { CSSProperties, ReactNode, RefObject } from 'react'; -import type { Editor } from '../services/editor/editor'; +import type { Editor, IEditorCanvasStyle } from '../services/editor/editor'; import type { IKeyboardEventConfig } from './rich-text-editor/hooks'; import { BuildTextUtils, createInternalEditorID, generateRandomId, getPlainText, ICommandService, IUniverInstanceService } from '@univerjs/core'; import { borderClassName, clsx } from '@univerjs/design'; @@ -53,6 +53,7 @@ export interface IRichTextEditorProps { icon?: ReactNode; editorRef?: RefObject | ((editor: Editor | null) => void); noStyle?: boolean; + canvasStyle?: IEditorCanvasStyle; } export const RichTextEditor = (props: IRichTextEditorProps) => { @@ -76,6 +77,7 @@ export const RichTextEditor = (props: IRichTextEditorProps) => { editorRef, placeholder, noStyle, + canvasStyle, } = props; const editorService = useDependency(IEditorService); const commandService = useDependency(ICommandService); @@ -92,6 +94,7 @@ export const RichTextEditor = (props: IRichTextEditorProps) => { preserveHostFocus, autoFocus, isSingle, + canvasStyle, }); const renderManagerService = useDependency(IRenderManagerService); const renderer = renderManagerService.getRenderUnitById(editorId); diff --git a/packages/docs-ui/src/views/mobile-rich-text-toolbar/MobileRichTextToolbar.tsx b/packages/docs-ui/src/views/mobile-rich-text-toolbar/MobileRichTextToolbar.tsx new file mode 100644 index 0000000000..3d73278725 --- /dev/null +++ b/packages/docs-ui/src/views/mobile-rich-text-toolbar/MobileRichTextToolbar.tsx @@ -0,0 +1,263 @@ +/** + * 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 { ITextStyle, Nullable } from '@univerjs/core'; +import type { ReactNode } from 'react'; +import type { LocaleKey } from '../../locale/types'; +import type { Editor } from '../../services/editor/editor'; +import { BooleanNumber, BuildTextUtils, ICommandService, LocaleService, PresetListType, ThemeService } from '@univerjs/core'; +import { clsx, resetButtonClassName } from '@univerjs/design'; +import { + BoldIcon, + ColorWheelMultiIcon, + FontColorDoubleIcon, + ItalicIcon, + MoreLeftIcon, + NoColorDoubleIcon, + OrderIcon, + StrikethroughIcon, + UnderlineIcon, + UnorderIcon, +} from '@univerjs/icons'; +import { useDependency, useObservable } from '@univerjs/ui'; +import { useEffect, useState } from 'react'; +import { merge } from 'rxjs'; +import { + getStyleInTextRange, + ResetInlineFormatTextColorCommand, + SetInlineFormatBoldCommand, + SetInlineFormatItalicCommand, + SetInlineFormatStrikethroughCommand, + SetInlineFormatTextColorCommand, + SetInlineFormatUnderlineCommand, +} from '../../commands/commands/inline-format.command'; +import { + BulletListCommand, + OrderListCommand, +} from '../../commands/commands/list.command'; +import { IEditorService } from '../../services/editor/editor-manager.service'; + +const COLOR_TOKENS = ['gray.900', 'red.500', 'yellow.500', 'green.500', 'blue.500'] as const; +const mobileButtonClassName = ` + univer-flex univer-min-w-0 univer-items-center univer-justify-center univer-rounded-lg univer-text-lg + univer-text-gray-800 active:univer-scale-95 active:univer-bg-primary-100 + dark:!univer-text-gray-100 dark:active:!univer-bg-gray-700 +`; +const activeButtonClassName = 'univer-bg-primary-100 univer-text-primary-600 dark:!univer-bg-gray-700 dark:!univer-text-primary-400'; + +export interface IMobileRichTextToolbarProps { + editorId: string; + className?: string; +} + +export function MobileRichTextToolbar({ editorId, className }: IMobileRichTextToolbarProps) { + const commandService = useDependency(ICommandService); + const editorService = useDependency(IEditorService); + const localeService = useDependency(LocaleService); + const themeService = useDependency(ThemeService); + const [, setRevision] = useState(0); + const [colorsVisible, setColorsVisible] = useState(false); + + useObservable(() => themeService.currentTheme$, undefined, false, [themeService]); + const editor = editorService.getEditor(editorId); + + useEffect(() => { + const frame = requestAnimationFrame(() => setRevision((value) => value + 1)); + return () => cancelAnimationFrame(frame); + }, [editorId, editorService]); + + useEffect(() => { + if (!editor) return undefined; + const subscription = merge(editor.input$, editor.selectionChange$).subscribe(() => setRevision((value) => value + 1)); + return () => subscription.unsubscribe(); + }, [editor]); + + const defaultTextColor = themeService.getColorFromTheme('gray.900'); + const state = resolveToolbarState(editor, defaultTextColor); + const colors = COLOR_TOKENS.map((token) => themeService.getColorFromTheme(token)); + + async function execute(commandId: string, params?: object) { + const activeEditor = editorService.getEditor(editorId); + if (editorService.getFocusId() !== editorId) { + editorService.focus(editorId); + } else { + // Full-screen mobile editors can keep their editor id while the canvas focus is + // recreated. Restore the real doc focus before applying a toolbar command. + activeEditor?.focus(); + } + + if (activeEditor && activeEditor.getSelectionRanges().length === 0) { + const end = Math.max(0, (activeEditor.getDocumentData().body?.dataStream.length ?? 2) - 2); + activeEditor.setSelectionRanges([{ startOffset: end, endOffset: end }]); + } + await commandService.executeCommand(commandId, params); + setRevision((value) => value + 1); + } + + return ( +
{ + event.preventDefault(); + event.stopPropagation(); + }} + > + {!colorsVisible + ? ( +
+ ('docs-ui.toolbar.bold')} active={state.bold} icon={} onClick={() => execute(SetInlineFormatBoldCommand.id)} /> + ('docs-ui.toolbar.italic')} active={state.italic} icon={} onClick={() => execute(SetInlineFormatItalicCommand.id)} /> + ('docs-ui.toolbar.underline')} active={state.underline} icon={} onClick={() => execute(SetInlineFormatUnderlineCommand.id)} /> + ('docs-ui.toolbar.strikethrough')} active={state.strike} icon={} onClick={() => execute(SetInlineFormatStrikethroughCommand.id)} /> + + ('docs-ui.toolbar.unorder')} active={state.bulletList} icon={} onClick={() => execute(BulletListCommand.id)} /> + ('docs-ui.toolbar.order')} active={state.orderList} icon={} onClick={() => execute(OrderListCommand.id)} /> +
+ ) + : ( +
+ + + {colors.map((color) => ( +
+ )} +
+ ); +} + +function FormatButton(props: { + label: string; + active: boolean; + icon: ReactNode; + onClick: () => void; +}) { + return ( + + ); +} + +function resolveToolbarState(editor: Readonly>, defaultTextColor: string): { + bold: boolean; + italic: boolean; + underline: boolean; + strike: boolean; + color: string; + bulletList: boolean; + orderList: boolean; +} { + const body = editor?.getDocumentData().body; + const ranges = editor?.getSelectionRanges() ?? []; + const range = ranges.find((item) => item.isActive) ?? ranges[0]; + if (!body || !range) { + return { bold: false, italic: false, underline: false, strike: false, color: defaultTextColor, bulletList: false, orderList: false }; + } + + const style: ITextStyle = getStyleInTextRange(body, range, {}); + const paragraphs = BuildTextUtils.range.getParagraphsInRanges([range], body.paragraphs ?? [], body.dataStream); + const listTypes = paragraphs + .map((paragraph) => paragraph.bullet?.listType) + .filter((listType): listType is string => typeof listType === 'string'); + return { + bold: style.bl === BooleanNumber.TRUE, + italic: style.it === BooleanNumber.TRUE, + underline: style.ul?.s === BooleanNumber.TRUE, + strike: style.st?.s === BooleanNumber.TRUE, + color: style.cl?.rgb ?? defaultTextColor, + bulletList: Boolean(listTypes.length && listTypes.every((listType) => listType.startsWith(PresetListType.BULLET_LIST))), + orderList: Boolean(listTypes.length && listTypes.every((listType) => listType.startsWith(PresetListType.ORDER_LIST))), + }; +} diff --git a/packages/docs-ui/src/views/rich-text-editor/hooks/__tests__/use-editor.spec.ts b/packages/docs-ui/src/views/rich-text-editor/hooks/__tests__/use-editor.spec.ts index c316e384f2..cf5c7bc21e 100644 --- a/packages/docs-ui/src/views/rich-text-editor/hooks/__tests__/use-editor.spec.ts +++ b/packages/docs-ui/src/views/rich-text-editor/hooks/__tests__/use-editor.spec.ts @@ -17,11 +17,12 @@ import type { IDocumentData } from '@univerjs/core'; import type { RefObject } from 'react'; import { validateDocBodyStructure } from '@univerjs/core'; -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { useEditor } from '../use-editor'; -const { editor, getEditor, register, setEditor } = vi.hoisted(() => ({ - editor: {}, +const { editor, focus, getEditor, register, setEditor } = vi.hoisted(() => ({ + editor: { setSelectionRanges: vi.fn() }, + focus: vi.fn(), getEditor: vi.fn(), register: vi.fn(), setEditor: vi.fn(), @@ -29,7 +30,7 @@ const { editor, getEditor, register, setEditor } = vi.hoisted(() => ({ vi.mock('@univerjs/ui', async (importOriginal) => ({ ...await importOriginal(), - useDependency: () => ({ getEditor, register }), + useDependency: () => ({ focus, getEditor, register }), })); vi.mock('react', async (importOriginal) => ({ @@ -40,6 +41,11 @@ vi.mock('react', async (importOriginal) => ({ })); describe('useEditor', () => { + afterEach(() => { + vi.clearAllMocks(); + vi.unstubAllGlobals(); + }); + it('registers a structurally valid document for a string initial value', () => { getEditor.mockReturnValue(editor); @@ -55,19 +61,46 @@ describe('useEditor', () => { expect(snapshot.body?.paragraphs?.map((paragraph) => paragraph.startIndex)).toEqual([2]); }); - it('registers an editor that preserves its host focus', () => { + it('registers an editor with its host focus and canvas style', () => { getEditor.mockReturnValue(editor); + const canvasStyle = { backgroundColor: '#000000' }; useEditor({ editorId: 'range-editor', initialValue: 'A1', container: { current: { clientWidth: 320 } } as RefObject, preserveHostFocus: true, + canvasStyle, }); expect(register).toHaveBeenLastCalledWith( - expect.objectContaining({ preserveHostFocus: true }), + expect.objectContaining({ canvasStyle, preserveHostFocus: true }), expect.anything() ); }); + + it('restores an auto-focused cursor at the content end after layout', () => { + let frameCallback: FrameRequestCallback | undefined; + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + frameCallback = callback; + return 1; + }); + getEditor.mockReturnValue(editor); + + useEditor({ + editorId: 'range-editor', + initialValue: 'A1', + container: { current: { clientWidth: 320 } } as RefObject, + autoFocus: true, + }); + + expect(focus).toHaveBeenCalledWith('range-editor'); + expect(editor.setSelectionRanges).not.toHaveBeenCalled(); + if (!frameCallback) { + throw new Error('Focus frame was not scheduled'); + } + frameCallback(0); + expect(editor.setSelectionRanges).toHaveBeenLastCalledWith([{ startOffset: 2, endOffset: 2 }]); + expect(editor.setSelectionRanges).toHaveBeenCalledOnce(); + }); }); diff --git a/packages/docs-ui/src/views/rich-text-editor/hooks/use-editor.ts b/packages/docs-ui/src/views/rich-text-editor/hooks/use-editor.ts index 897998a06d..5923fe1dc3 100644 --- a/packages/docs-ui/src/views/rich-text-editor/hooks/use-editor.ts +++ b/packages/docs-ui/src/views/rich-text-editor/hooks/use-editor.ts @@ -40,6 +40,7 @@ export function useEditor(opts: IUseEditorProps) { useLayoutEffect(() => { if (container.current) { + let focusFrame: number | undefined; const initialDoc = typeof initialValue === 'string' ? RichTextBuilder.create().insertText(initialValue).getData() : Tools.deepClone(initialValue); @@ -81,10 +82,15 @@ export function useEditor(opts: IUseEditorProps) { if (autoFocus) { editorService.focus(editorId); const end = (snapshot.body?.dataStream.length ?? 2) - 2; - editor.setSelectionRanges([{ startOffset: end, endOffset: end }]); + focusFrame = requestAnimationFrame(() => { + editor.setSelectionRanges([{ startOffset: end, endOffset: end }]); + }); } return () => { + if (focusFrame !== undefined) { + cancelAnimationFrame(focusFrame); + } dispose?.dispose(); }; } diff --git a/packages/drawing-ui/src/views/__tests__/DrawingPanelActions.spec.tsx b/packages/drawing-ui/src/views/__tests__/DrawingPanelActions.spec.tsx index f3a5f9a11b..5d138f5c6a 100644 --- a/packages/drawing-ui/src/views/__tests__/DrawingPanelActions.spec.tsx +++ b/packages/drawing-ui/src/views/__tests__/DrawingPanelActions.spec.tsx @@ -20,9 +20,10 @@ import type { ReactElement } from 'react'; import type { Root } from 'react-dom/client'; import { ArrangeTypeEnum, CommandType, DrawingTypeEnum, ICommandService, LocaleType, Univer } from '@univerjs/core'; import { DrawingManagerService, IDrawingManagerService } from '@univerjs/drawing'; -import { ComponentManager, IconManager, RediContext } from '@univerjs/ui'; +import { ComponentManager, IconManager, IDialogService, RediContext } from '@univerjs/ui'; import { act } from 'react'; import { createRoot } from 'react-dom/client'; +import { of } from 'rxjs'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { SetDrawingArrangeOperation } from '../../commands/operations/drawing-arrange.operation'; import { @@ -118,6 +119,14 @@ describe('drawing panel actions', () => { injector.add([IconManager]); injector.add([ComponentManager]); injector.add([DrawingImageClipService]); + injector.add([IDialogService, { + useValue: { + open: () => ({ dispose: () => undefined }), + close: () => undefined, + closeAll: () => undefined, + getDialogs$: () => of([]), + }, + }]); injector.get(IconManager).register({ DrawingEditIcon: () => }); commandService = injector.get(ICommandService); diff --git a/packages/drawing-ui/src/views/image-popup-menu/ImagePopupMenu.tsx b/packages/drawing-ui/src/views/image-popup-menu/ImagePopupMenu.tsx index 8109c9f5de..87b5ce9b40 100644 --- a/packages/drawing-ui/src/views/image-popup-menu/ImagePopupMenu.tsx +++ b/packages/drawing-ui/src/views/image-popup-menu/ImagePopupMenu.tsx @@ -25,15 +25,16 @@ import { PositionedObjectLayoutType, UniverInstanceType, } from '@univerjs/core'; -import { borderClassName, clsx, Dropdown, DropdownMenu, Separator, Tooltip } from '@univerjs/design'; +import { borderClassName, clsx, ConfigContext, Dropdown, DropdownMenu, Separator, Tooltip } from '@univerjs/design'; import { AutofillDoubleIcon, ChartIcon, MoreDownIcon, TextWrapShapeIcon, } from '@univerjs/icons'; -import { IconManager, useDependency } from '@univerjs/ui'; -import { useState } from 'react'; +import { IconManager, IDialogService, useDependency } from '@univerjs/ui'; +import { useContext, useState } from 'react'; +import { MobileImagePopupMenu } from './MobileImagePopupMenu'; export interface IImagePopupMenuItem { label: string; @@ -55,6 +56,7 @@ export interface IImagePopupMenuExtraProps { unitId?: string; subUnitId?: string; drawingId?: string; + dialogId?: string; } export interface IImagePopupMenuProps { @@ -69,6 +71,8 @@ export function ImagePopupMenu(props: IImagePopupMenuProps) { const menuItems = popup?.extraProps?.menuItems; const commandService = useDependency(ICommandService); const localeService = useDependency(LocaleService); + const dialogService = useDependency(IDialogService); + const { mobile } = useContext(ConfigContext); const [visible, setVisible] = useState(false); const [isHovered, setIsHovered] = useState(false); @@ -94,6 +98,21 @@ export function ImagePopupMenu(props: IImagePopupMenuProps) { return ; } + if (mobile) { + return ( + localeService.t(item.label)} + onSelect={async (item) => { + await commandService.executeCommand(item.commandId, item.commandParams); + if (popup.extraProps?.dialogId) { + dialogService.close(popup.extraProps.dialogId); + } + }} + /> + ); + } + const handleMouseEnter = () => { setIsHovered(true); }; diff --git a/packages/drawing-ui/src/views/image-popup-menu/MobileImagePopupMenu.tsx b/packages/drawing-ui/src/views/image-popup-menu/MobileImagePopupMenu.tsx new file mode 100644 index 0000000000..da7b457407 --- /dev/null +++ b/packages/drawing-ui/src/views/image-popup-menu/MobileImagePopupMenu.tsx @@ -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 { IImagePopupMenuItem } from './ImagePopupMenu'; +import { MobileActionRow } from '@univerjs/design'; + +interface IMobileImagePopupMenuProps { + menuItems: IImagePopupMenuItem[]; + getLabel: (item: IImagePopupMenuItem) => string; + onSelect: (item: IImagePopupMenuItem) => void; +} + +export function MobileImagePopupMenu({ menuItems, getLabel, onSelect }: IMobileImagePopupMenuProps) { + return ( +
+ {menuItems.map((item) => { + const label = getLabel(item); + + return ( + onSelect(item)} + /> + ); + })} +
+ ); +} diff --git a/packages/sheets-conditional-formatting-ui/src/mobile-plugin.ts b/packages/sheets-conditional-formatting-ui/src/mobile-plugin.ts index fda98b2a1b..22d8749cce 100644 --- a/packages/sheets-conditional-formatting-ui/src/mobile-plugin.ts +++ b/packages/sheets-conditional-formatting-ui/src/mobile-plugin.ts @@ -23,6 +23,8 @@ import { Injector, merge, Plugin, + registerDependencies, + touchDependencies, UniverInstanceType, } from '@univerjs/core'; import { UniverRenderEnginePlugin } from '@univerjs/engine-render'; @@ -46,8 +48,11 @@ import { defaultPluginConfig, SHEETS_CONDITIONAL_FORMATTING_UI_PLUGIN_CONFIG_KEY import { ConditionalFormattingFormulaRefRangeController } from './controllers/cf-formula-ref-range.controller'; import { ConditionalFormattingCopyPasteController } from './controllers/cf.copy-paste.controller'; import { ConditionalFormattingI18nController } from './controllers/cf.i18n.controller'; +import { ConditionalFormattingPanelController } from './controllers/cf.panel.controller'; import { ConditionalFormattingPermissionController } from './controllers/cf.permission.controller'; import { SheetsCfRenderController } from './controllers/cf.render.controller'; +import { ComponentsController } from './controllers/components.controller'; +import { ConditionalFormattingMenuController } from './menu/cf.menu.controller'; @DependentOn( UniverRenderEnginePlugin, @@ -82,12 +87,39 @@ export class UniverSheetsConditionalFormattingMobileUIPlugin extends Plugin { this._configService.setConfig(SHEETS_CONDITIONAL_FORMATTING_UI_PLUGIN_CONFIG_KEY, rest); this._initCommand(); + } - this._injector.add([SheetsCfRenderController]); - this._injector.add([ConditionalFormattingCopyPasteController]); - this._injector.add([ConditionalFormattingPermissionController]); - this._injector.add([ConditionalFormattingI18nController]); - this._injector.add([ConditionalFormattingFormulaRefRangeController]); + override onStarting(): void { + registerDependencies(this._injector, [ + [ComponentsController], + [SheetsCfRenderController], + [ConditionalFormattingCopyPasteController], + [ConditionalFormattingPermissionController], + [ConditionalFormattingI18nController], + [ConditionalFormattingFormulaRefRangeController], + [ConditionalFormattingPanelController], + [ConditionalFormattingMenuController], + ]); + touchDependencies(this._injector, [ + [ComponentsController], + [SheetsCfRenderController], + [ConditionalFormattingFormulaRefRangeController], + ]); + } + + override onReady(): void { + touchDependencies(this._injector, [ + [ConditionalFormattingMenuController], + [ConditionalFormattingPanelController], + ]); + } + + override onRendered(): void { + touchDependencies(this._injector, [ + [ConditionalFormattingCopyPasteController], + [ConditionalFormattingPermissionController], + [ConditionalFormattingI18nController], + ]); } private _initCommand() { diff --git a/packages/sheets-conditional-formatting-ui/src/views/__tests__/ConditionFormattingPanel.spec.tsx b/packages/sheets-conditional-formatting-ui/src/views/__tests__/ConditionFormattingPanel.spec.tsx index 03786c4283..8f0d67a049 100644 --- a/packages/sheets-conditional-formatting-ui/src/views/__tests__/ConditionFormattingPanel.spec.tsx +++ b/packages/sheets-conditional-formatting-ui/src/views/__tests__/ConditionFormattingPanel.spec.tsx @@ -23,9 +23,10 @@ import { IRenderManagerService } from '@univerjs/engine-render'; import { SetWorksheetActiveOperation } from '@univerjs/sheets'; import { AddCfCommand, CFNumberOperator, CFRuleType, CFSubRuleType, SetCfCommand } from '@univerjs/sheets-conditional-formatting'; import { IMarkSelectionService } from '@univerjs/sheets-ui'; -import { IShortcutService, RediContext } from '@univerjs/ui'; +import { IDialogService, IShortcutService, RediContext } from '@univerjs/ui'; import { act } from 'react'; import { createRoot } from 'react-dom/client'; +import { of } from 'rxjs'; import { afterEach, describe, expect, it } from 'vitest'; import { createCfUiTestBed } from '../../__tests__/create-cf-ui-test-bed'; import { ConditionalFormattingI18nController } from '../../controllers/cf.i18n.controller'; @@ -88,6 +89,20 @@ class TestDescriptionService { } } +class TestDialogService { + open() { + return { dispose: () => {} }; + } + + close(): void {} + + closeAll(): void {} + + getDialogs$() { + return of([]); + } +} + function createNumberHighlightRule(cfId: string, value: number): IConditionFormattingRule { return { cfId, @@ -113,6 +128,7 @@ function createPanelTestBed() { testBed.injector.add([IShortcutService, { useClass: TestShortcutService as never }]); testBed.injector.add([IMarkSelectionService, { useClass: TestMarkSelectionService as never }]); testBed.injector.add([IDescriptionService, { useClass: TestDescriptionService as never }]); + testBed.injector.add([IDialogService, { useClass: TestDialogService }]); testBed.injector.add([LexerTreeBuilder]); testBed.commandService.registerCommand(AddCfCommand); testBed.commandService.registerCommand(SetCfCommand); diff --git a/packages/sheets-conditional-formatting-ui/src/views/panel/rule-edit/__tests__/RuleEditors.spec.tsx b/packages/sheets-conditional-formatting-ui/src/views/panel/rule-edit/__tests__/RuleEditors.spec.tsx index 513f53d293..65d53d3464 100644 --- a/packages/sheets-conditional-formatting-ui/src/views/panel/rule-edit/__tests__/RuleEditors.spec.tsx +++ b/packages/sheets-conditional-formatting-ui/src/views/panel/rule-edit/__tests__/RuleEditors.spec.tsx @@ -29,10 +29,10 @@ import { CFValueType, IIconSetType, } from '@univerjs/sheets-conditional-formatting'; -import { IContextMenuService, ILayoutService, IShortcutService, RediContext } from '@univerjs/ui'; +import { IContextMenuService, IDialogService, ILayoutService, IShortcutService, RediContext } from '@univerjs/ui'; import { act } from 'react'; import { createRoot } from 'react-dom/client'; -import { Subject } from 'rxjs'; +import { of, Subject } from 'rxjs'; import { afterEach, describe, expect, it } from 'vitest'; import { createCfUiTestBed } from '../../../../__tests__/create-cf-ui-test-bed'; import enUS from '../../../../locale/en-US'; @@ -87,6 +87,20 @@ class TestDescriptionService { } } +class TestDialogService { + open() { + return { dispose: () => {} }; + } + + close(): void {} + + closeAll(): void {} + + getDialogs$() { + return of([]); + } +} + class TestRefSelectionsService { setSelections(): void {} @@ -111,6 +125,7 @@ function createEditorTestBed() { testBed.injector.add([IEditorService, { useClass: TestEditorService as never }]); testBed.injector.add([IRenderManagerService, { useClass: TestRenderManagerService as never }]); testBed.injector.add([IDescriptionService, { useClass: TestDescriptionService as never }]); + testBed.injector.add([IDialogService, { useClass: TestDialogService }]); testBed.injector.add([IRefSelectionsService, { useClass: TestRefSelectionsService as never }]); testBed.injector.add([IShortcutService, { useClass: TestShortcutService as never }]); testBed.injector.add([IContextMenuService, { useClass: TestContextMenuService as never }]); diff --git a/packages/sheets-data-validation-ui/src/menu/__tests__/dv.menu.spec.ts b/packages/sheets-data-validation-ui/src/menu/__tests__/dv.menu.spec.ts new file mode 100644 index 0000000000..7a87a85a86 --- /dev/null +++ b/packages/sheets-data-validation-ui/src/menu/__tests__/dv.menu.spec.ts @@ -0,0 +1,28 @@ +/** + * Copyright 2023-present DreamNum Co., Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, expect, it } from 'vitest'; +import { OpenValidationPanelOperation } from '../../commands/operations/data-validation.operation'; +import { openDataValidationMenuFactory } from '../dv.menu'; + +describe('data validation menu', () => { + it('passes the empty parameter object required to open the manager', () => { + expect(openDataValidationMenuFactory()).toMatchObject({ + id: OpenValidationPanelOperation.id, + params: {}, + }); + }); +}); diff --git a/packages/sheets-data-validation-ui/src/menu/dv.menu.ts b/packages/sheets-data-validation-ui/src/menu/dv.menu.ts index 8eee890dba..3f0324c2ff 100644 --- a/packages/sheets-data-validation-ui/src/menu/dv.menu.ts +++ b/packages/sheets-data-validation-ui/src/menu/dv.menu.ts @@ -205,6 +205,7 @@ export function openDataValidationMenuFactory(): IMenuItem { id: OpenValidationPanelOperation.id, title: 'sheets-data-validation-ui.panel.title', type: MenuItemType.BUTTON, + params: {}, }; } diff --git a/packages/sheets-data-validation-ui/src/views/components/DataValidationDetail.tsx b/packages/sheets-data-validation-ui/src/views/components/DataValidationDetail.tsx index 232085ded6..e2b37803d3 100644 --- a/packages/sheets-data-validation-ui/src/views/components/DataValidationDetail.tsx +++ b/packages/sheets-data-validation-ui/src/views/components/DataValidationDetail.tsx @@ -46,7 +46,7 @@ import { getRuleSetting, TWO_FORMULA_OPERATOR_COUNT, } from '@univerjs/data-validation'; -import { Button, Checkbox, FormLayout, Select } from '@univerjs/design'; +import { ActionRow, Button, Checkbox, FormLayout, Select } from '@univerjs/design'; import { deserializeRangeWithSheet, serializeRange } from '@univerjs/engine-formula'; import { SetWorksheetActiveOperation, SheetsSelectionsService } from '@univerjs/sheets'; import { @@ -417,14 +417,14 @@ function DataValidationDetailInner(props: { activeRuleInfo: { unitId: string; su -
+ -
+ ); } diff --git a/packages/sheets-data-validation-ui/src/views/components/DataValidationList.tsx b/packages/sheets-data-validation-ui/src/views/components/DataValidationList.tsx index 9b3b9af1f4..05c1a0e8a2 100644 --- a/packages/sheets-data-validation-ui/src/views/components/DataValidationList.tsx +++ b/packages/sheets-data-validation-ui/src/views/components/DataValidationList.tsx @@ -18,7 +18,7 @@ import type { ISheetDataValidationRule, Workbook } from '@univerjs/core'; import type { IAddSheetDataValidationCommandParams } from '@univerjs/sheets-data-validation'; import type { LocaleKey } from '../../locale/types'; import { ICommandService, Injector, IUniverInstanceService, LocaleService, UniverInstanceType } from '@univerjs/core'; -import { Button } from '@univerjs/design'; +import { ActionRow, Button } from '@univerjs/design'; import { checkRangesEditablePermission } from '@univerjs/sheets'; import { AddSheetDataValidationCommand, @@ -115,8 +115,9 @@ export function DataValidationList(props: { workbook: Workbook }) { disable={rule.disable ?? false} /> ))} -
- + {(rules.length && !hasDisableRule) ? ( -
+ ); }; diff --git a/packages/sheets-data-validation-ui/src/views/components/__tests__/DataValidationDetail.spec.tsx b/packages/sheets-data-validation-ui/src/views/components/__tests__/DataValidationDetail.spec.tsx index bb1f59bda6..6f24865241 100644 --- a/packages/sheets-data-validation-ui/src/views/components/__tests__/DataValidationDetail.spec.tsx +++ b/packages/sheets-data-validation-ui/src/views/components/__tests__/DataValidationDetail.spec.tsx @@ -97,6 +97,7 @@ import { IMarkSelectionService } from '@univerjs/sheets-ui'; import { ComponentManager, DesktopSidebarService, + IDialogService, ILayoutService, IPlatformService, IShortcutService, @@ -109,7 +110,7 @@ import { } from '@univerjs/ui'; import { act } from 'react'; import { createRoot } from 'react-dom/client'; -import { Subject } from 'rxjs'; +import { of, Subject } from 'rxjs'; import { afterEach, describe, expect, it } from 'vitest'; import { DataValidationPanelService } from '../../../services/data-validation-panel.service'; import { DataValidationDetail } from '../DataValidationDetail'; @@ -167,6 +168,20 @@ class TestRenderManagerService { class TestDescriptionService { } +class TestDialogService { + open(): IDisposable { + return toDisposable(() => undefined); + } + + close(): void {} + + closeAll(): void {} + + getDialogs$() { + return of([]); + } +} + const IEditorService = createIdentifier('univer.editor.service'); const IDescriptionService = createIdentifier('formula.description-service'); @@ -404,6 +419,7 @@ function createDetailTestBed(rule: ISheetDataValidationRule) { [SheetInterceptorService], [SheetSkeletonService], [ComponentManager], + [IDialogService, { useClass: TestDialogService }], [IEditorService, { useClass: TestEditorService }], [IDescriptionService, { useClass: TestDescriptionService }], [IRenderManagerService, { useClass: TestRenderManagerService as never }], diff --git a/packages/sheets-drawing-ui/src/menu/__tests__/drawing-popup-menu.controller.spec.ts b/packages/sheets-drawing-ui/src/menu/__tests__/drawing-popup-menu.controller.spec.ts index 4172a62a9b..784bab2511 100644 --- a/packages/sheets-drawing-ui/src/menu/__tests__/drawing-popup-menu.controller.spec.ts +++ b/packages/sheets-drawing-ui/src/menu/__tests__/drawing-popup-menu.controller.spec.ts @@ -15,6 +15,7 @@ */ import { + ContextService, DrawingTypeEnum, ICommandService, IContextService, @@ -27,7 +28,7 @@ import { import { IDrawingManagerService } from '@univerjs/drawing'; import { IRenderManagerService } from '@univerjs/engine-render'; import { SheetCanvasPopManagerService } from '@univerjs/sheets-ui'; -import { IMenuManagerService, IMessageService, MenuItemType } from '@univerjs/ui'; +import { IDialogService, IMenuManagerService, IMessageService, MenuItemType } from '@univerjs/ui'; import { Subject } from 'rxjs'; import { describe, expect, it, vi } from 'vitest'; import { DrawingPopupMenuController } from '../drawing-popup-menu.controller'; @@ -41,10 +42,10 @@ describe('DrawingPopupMenuController', () => { const createControl$ = new Subject(); const clearControl$ = new Subject(); const changing$ = new Subject(); - const contextChanged$ = new Subject>(); const imageIoChange$ = new Subject(); const currentWorkbook$ = new Subject(); const disposedWorkbook$ = new Subject(); + const dialogs$ = new Subject(); const imageObject = { oKey: 'image-1' }; let attachedPopup: { extraProps?: Record } | undefined; const attachPopupToObject = vi.fn((_targetObject: unknown, popup: { extraProps?: Record }) => { @@ -56,6 +57,14 @@ describe('DrawingPopupMenuController', () => { }); injector.add([LocaleService]); + injector.add([IDialogService, { + useValue: { + open: () => toDisposable(() => undefined), + close: () => undefined, + closeAll: () => undefined, + getDialogs$: () => dialogs$, + }, + }]); injector.add([IDrawingManagerService, { useValue: { getDrawingOKey: () => ({ @@ -109,12 +118,7 @@ describe('DrawingPopupMenuController', () => { }], } as never, }]); - injector.add([IContextService, { - useValue: { - contextChanged$, - setContextValue: vi.fn(), - } as never, - }]); + injector.add([IContextService, { useValue: new ContextService() }]); injector.add([IImageIoService, { useValue: { change$: imageIoChange$ } as never }]); injector.add([ICommandService, { useValue: { syncExecuteCommand: vi.fn() } as never }]); injector.add([DrawingPopupMenuController]); diff --git a/packages/sheets-drawing-ui/src/menu/drawing-popup-menu.controller.ts b/packages/sheets-drawing-ui/src/menu/drawing-popup-menu.controller.ts index 8c7a58939a..bae94b1567 100644 --- a/packages/sheets-drawing-ui/src/menu/drawing-popup-menu.controller.ts +++ b/packages/sheets-drawing-ui/src/menu/drawing-popup-menu.controller.ts @@ -24,13 +24,16 @@ import { FOCUSING_COMMON_DRAWINGS, ICommandService, IContextService, + IImageIoService, Inject, - Injector, IUniverInstanceService, LocaleService, + RxDisposable, + toDisposable, UniverInstanceType, + } from '@univerjs/core'; import { MessageType } from '@univerjs/design'; import { IDrawingManagerService, SetDrawingSelectedOperation } from '@univerjs/drawing'; @@ -45,18 +48,21 @@ import { RemoveSheetDrawingCommand } from '@univerjs/sheets-drawing'; import { SheetCanvasPopManagerService } from '@univerjs/sheets-ui'; import { FloatingObjectToolbarPosition, + IDialogService, IMenuManagerService, IMessageService, MenuItemType, + MOBILE_UI_MODE, } from '@univerjs/ui'; import { FlipSheetDrawingCommand } from '../commands/commands/flip-drawings.command'; import { EditSheetDrawingOperation } from '../commands/operations/edit-sheet-drawing.operation'; +const MOBILE_IMAGE_ACTIONS_DIALOG_ID = 'sheet-mobile-image-actions'; + export class DrawingPopupMenuController extends RxDisposable { private _initImagePopupMenu = new Set(); constructor( - @Inject(Injector) private _injector: Injector, @Inject(LocaleService) private readonly _localeService: LocaleService, @IDrawingManagerService private readonly _drawingManagerService: IDrawingManagerService, @Inject(SheetCanvasPopManagerService) private readonly _canvasPopManagerService: SheetCanvasPopManagerService, @@ -66,7 +72,8 @@ export class DrawingPopupMenuController extends RxDisposable { @IMenuManagerService private readonly _menuManagerService: IMenuManagerService, @IContextService private readonly _contextService: IContextService, @IImageIoService private readonly _ioService: ImageIoService, - @ICommandService private readonly _commandService: ICommandService + @ICommandService private readonly _commandService: ICommandService, + @IDialogService private readonly _dialogService: IDialogService ) { super(); @@ -174,18 +181,41 @@ export class DrawingPopupMenuController extends RxDisposable { return; } - singletonPopupDisposer?.dispose(); const menus = this._canvasPopManagerService.getFeatureMenu(unitId, subUnitId, drawingId, drawingType); + const menuItems = [ + ...(menus || this._getImageMenuItems(unitId, subUnitId, drawingId, drawingType)), + ...this._getFloatingObjectMenuItems(), + ]; + singletonPopupDisposer?.dispose(); + const mobileDialogService = this._getMobileDialogService(); + if (mobileDialogService) { + mobileDialogService.open({ + id: MOBILE_IMAGE_ACTIONS_DIALOG_ID, + title: { title: 'sheets-drawing-ui.image-popup.edit' }, + children: { + label: { + name: COMPONENT_IMAGE_POPUP_MENU, + props: { + popup: { + extraProps: { + menuItems, + dialogId: MOBILE_IMAGE_ACTIONS_DIALOG_ID, + }, + }, + }, + }, + }, + }); + singletonPopupDisposer = toDisposable(() => mobileDialogService.close(MOBILE_IMAGE_ACTIONS_DIALOG_ID)); + return; + } singletonPopupDisposer = this.disposeWithMe(this._canvasPopManagerService.attachPopupToObject(object, { componentKey: COMPONENT_IMAGE_POPUP_MENU, constrainToCanvas: true, direction: this._localeService.getDirection() === 'rtl' ? 'left' : 'horizontal', offset: [2, 0], extraProps: { - menuItems: [ - ...(menus || this._getImageMenuItems(unitId, subUnitId, drawingId, drawingType)), - ...this._getFloatingObjectMenuItems(), - ], + menuItems, }, })); }) @@ -211,6 +241,10 @@ export class DrawingPopupMenuController extends RxDisposable { ); } + private _getMobileDialogService(): IDialogService | null { + return this._contextService.getContextValue(MOBILE_UI_MODE) ? this._dialogService : null; + } + private _getImageMenuItems(unitId: string, subUnitId: string, drawingId: string, drawingType: number) { return [ { diff --git a/packages/sheets-filter-ui/src/controllers/mobile-menu.controller.ts b/packages/sheets-filter-ui/src/controllers/mobile-menu.controller.ts new file mode 100644 index 0000000000..d0efe156fe --- /dev/null +++ b/packages/sheets-filter-ui/src/controllers/mobile-menu.controller.ts @@ -0,0 +1,32 @@ +/** + * 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, ICommandService } from '@univerjs/core'; +import { SmartToggleSheetsFilterCommand } from '@univerjs/sheets-filter'; +import { IMenuManagerService } from '@univerjs/ui'; +import { menuSchema } from '../menu/schema'; + +export class SheetsFilterMobileMenuController extends Disposable { + constructor( + @ICommandService commandService: ICommandService, + @IMenuManagerService menuManagerService: IMenuManagerService + ) { + super(); + + this.disposeWithMe(commandService.registerCommand(SmartToggleSheetsFilterCommand)); + menuManagerService.mergeMenu(menuSchema); + } +} diff --git a/packages/sheets-filter-ui/src/mobile-plugin.ts b/packages/sheets-filter-ui/src/mobile-plugin.ts index af7f33aedd..0fe5c53cac 100644 --- a/packages/sheets-filter-ui/src/mobile-plugin.ts +++ b/packages/sheets-filter-ui/src/mobile-plugin.ts @@ -25,6 +25,7 @@ import { UniverMobileUIPlugin } from '@univerjs/ui'; import pkg from '../package.json'; import { defaultPluginConfig, SHEETS_FILTER_UI_PLUGIN_CONFIG_KEY } from './config/config'; import { ComponentsController } from './controllers/components.controller'; +import { SheetsFilterMobileMenuController } from './controllers/mobile-menu.controller'; import { SheetsFilterPermissionController } from './controllers/sheets-filter-permission.controller'; import { SheetsFilterUIMobileController } from './controllers/ui-mobile.controller'; @@ -65,12 +66,14 @@ export class UniverSheetsFilterMobileUIPlugin extends Plugin { this._injector.get(ComponentsController); ([ [SheetsFilterPermissionController], + [SheetsFilterMobileMenuController], [SheetsFilterUIMobileController], ] as Dependency[]).forEach((d) => this._injector.add(d)); } override onReady(): void { this._injector.get(SheetsFilterPermissionController); + this._injector.get(SheetsFilterMobileMenuController); } override onRendered(): void { diff --git a/packages/sheets-formula-ui/src/locale/ar-SA.ts b/packages/sheets-formula-ui/src/locale/ar-SA.ts index a66188e979..5ce353a015 100644 --- a/packages/sheets-formula-ui/src/locale/ar-SA.ts +++ b/packages/sheets-formula-ui/src/locale/ar-SA.ts @@ -75,6 +75,15 @@ const locale: typeof enUS = { allFunctions: 'كل الدوال', syntax: 'الصيغة', }, + mobileFunction: { + title: 'إدراج دالة', + recommended: 'موصى بها', + recent: 'الأخيرة', + details: 'التفاصيل', + insert: 'إدراج الدالة', + empty: 'لا توجد دوال', + close: 'إغلاق', + }, operation: { copyFormulaOnly: 'نسخ الصيغة فقط', pasteFormula: 'لصق الصيغة', diff --git a/packages/sheets-formula-ui/src/locale/ca-ES.ts b/packages/sheets-formula-ui/src/locale/ca-ES.ts index 8e793ba4c0..5e8a89b108 100644 --- a/packages/sheets-formula-ui/src/locale/ca-ES.ts +++ b/packages/sheets-formula-ui/src/locale/ca-ES.ts @@ -75,6 +75,15 @@ const locale: typeof enUS = { allFunctions: 'Totes les funcions', syntax: 'SINTAXI', }, + mobileFunction: { + title: 'Insereix una funció', + recommended: 'Recomanades', + recent: 'Recents', + details: 'Detalls', + insert: 'Insereix la funció', + empty: 'No hi ha funcions', + close: 'Tanca', + }, operation: { copyFormulaOnly: 'Copia només la fórmula', pasteFormula: 'Enganxa la fórmula', diff --git a/packages/sheets-formula-ui/src/locale/de-DE.ts b/packages/sheets-formula-ui/src/locale/de-DE.ts index 2fc9521ed0..53cd8896bf 100644 --- a/packages/sheets-formula-ui/src/locale/de-DE.ts +++ b/packages/sheets-formula-ui/src/locale/de-DE.ts @@ -75,6 +75,15 @@ const locale: typeof enUS = { allFunctions: 'Alle Funktionen', syntax: 'SYNTAX', }, + mobileFunction: { + title: 'Funktion einfügen', + recommended: 'Empfohlen', + recent: 'Zuletzt verwendet', + details: 'Details', + insert: 'Funktion einfügen', + empty: 'Keine Funktionen', + close: 'Schließen', + }, operation: { copyFormulaOnly: 'Nur Formel kopieren', pasteFormula: 'Formel einfügen', diff --git a/packages/sheets-formula-ui/src/locale/en-US.ts b/packages/sheets-formula-ui/src/locale/en-US.ts index 753b5f922b..7ee9c7716a 100644 --- a/packages/sheets-formula-ui/src/locale/en-US.ts +++ b/packages/sheets-formula-ui/src/locale/en-US.ts @@ -73,6 +73,15 @@ const locale = { allFunctions: 'All Functions', syntax: 'SYNTAX', }, + mobileFunction: { + title: 'Insert function', + recommended: 'Recommended', + recent: 'Recent', + details: 'Details', + insert: 'Insert function', + empty: 'No functions', + close: 'Close', + }, operation: { copyFormulaOnly: 'Copy Formula Only', pasteFormula: 'Paste Formula', diff --git a/packages/sheets-formula-ui/src/locale/es-ES.ts b/packages/sheets-formula-ui/src/locale/es-ES.ts index 882048a0a0..9181851830 100644 --- a/packages/sheets-formula-ui/src/locale/es-ES.ts +++ b/packages/sheets-formula-ui/src/locale/es-ES.ts @@ -75,6 +75,15 @@ const locale: typeof enUS = { allFunctions: 'Todas las funciones', syntax: 'SINTAXIS', }, + mobileFunction: { + title: 'Insertar función', + recommended: 'Recomendadas', + recent: 'Recientes', + details: 'Detalles', + insert: 'Insertar función', + empty: 'No hay funciones', + close: 'Cerrar', + }, operation: { copyFormulaOnly: 'Copiar solo fórmula', pasteFormula: 'Pegar fórmula', diff --git a/packages/sheets-formula-ui/src/locale/fa-IR.ts b/packages/sheets-formula-ui/src/locale/fa-IR.ts index 8bb610f676..d36bab5ead 100644 --- a/packages/sheets-formula-ui/src/locale/fa-IR.ts +++ b/packages/sheets-formula-ui/src/locale/fa-IR.ts @@ -75,6 +75,15 @@ const locale: typeof enUS = { allFunctions: 'همه توابع', syntax: 'سینتکس', }, + mobileFunction: { + title: 'درج تابع', + recommended: 'پیشنهادی', + recent: 'اخیر', + details: 'جزئیات', + insert: 'درج تابع', + empty: 'تابعی وجود ندارد', + close: 'بستن', + }, operation: { copyFormulaOnly: 'کپی فقط فرمول', pasteFormula: 'چسباندن فرمول', diff --git a/packages/sheets-formula-ui/src/locale/fr-FR.ts b/packages/sheets-formula-ui/src/locale/fr-FR.ts index df7224489d..1c5667712e 100644 --- a/packages/sheets-formula-ui/src/locale/fr-FR.ts +++ b/packages/sheets-formula-ui/src/locale/fr-FR.ts @@ -75,6 +75,15 @@ const locale: typeof enUS = { allFunctions: 'Toutes les fonctions', syntax: 'SYNTAXE', }, + mobileFunction: { + title: 'Insérer une fonction', + recommended: 'Recommandées', + recent: 'Récentes', + details: 'Détails', + insert: 'Insérer la fonction', + empty: 'Aucune fonction', + close: 'Fermer', + }, operation: { copyFormulaOnly: 'Copier uniquement la formule', pasteFormula: 'Coller la formule', diff --git a/packages/sheets-formula-ui/src/locale/id-ID.ts b/packages/sheets-formula-ui/src/locale/id-ID.ts index 3a6b355c07..3bd0fb119b 100644 --- a/packages/sheets-formula-ui/src/locale/id-ID.ts +++ b/packages/sheets-formula-ui/src/locale/id-ID.ts @@ -75,6 +75,15 @@ const locale: typeof enUS = { allFunctions: 'Semua Fungsi', syntax: 'SINTAKS', }, + mobileFunction: { + title: 'Sisipkan fungsi', + recommended: 'Direkomendasikan', + recent: 'Terbaru', + details: 'Detail', + insert: 'Sisipkan fungsi', + empty: 'Tidak ada fungsi', + close: 'Tutup', + }, operation: { copyFormulaOnly: 'Salin Hanya Rumus', pasteFormula: 'Tempel Rumus', diff --git a/packages/sheets-formula-ui/src/locale/it-IT.ts b/packages/sheets-formula-ui/src/locale/it-IT.ts index a7853bbe69..773ae545ce 100644 --- a/packages/sheets-formula-ui/src/locale/it-IT.ts +++ b/packages/sheets-formula-ui/src/locale/it-IT.ts @@ -75,6 +75,15 @@ const locale: typeof enUS = { allFunctions: 'Tutte le funzioni', syntax: 'SINTASSI', }, + mobileFunction: { + title: 'Inserisci funzione', + recommended: 'Consigliate', + recent: 'Recenti', + details: 'Dettagli', + insert: 'Inserisci funzione', + empty: 'Nessuna funzione', + close: 'Chiudi', + }, operation: { copyFormulaOnly: 'Copia solo formula', pasteFormula: 'Incolla formula', diff --git a/packages/sheets-formula-ui/src/locale/ja-JP.ts b/packages/sheets-formula-ui/src/locale/ja-JP.ts index fb3bca86a3..a1eeec4a9f 100644 --- a/packages/sheets-formula-ui/src/locale/ja-JP.ts +++ b/packages/sheets-formula-ui/src/locale/ja-JP.ts @@ -75,6 +75,15 @@ const locale: typeof enUS = { allFunctions: 'すべての関数', syntax: '構文', }, + mobileFunction: { + title: '関数を挿入', + recommended: 'おすすめ', + recent: '最近使用した関数', + details: '詳細', + insert: '関数を挿入', + empty: '関数がありません', + close: '閉じる', + }, operation: { copyFormulaOnly: '数式のみをコピー', pasteFormula: '数式を貼り付け', diff --git a/packages/sheets-formula-ui/src/locale/ko-KR.ts b/packages/sheets-formula-ui/src/locale/ko-KR.ts index 1770df31c6..72c2267c8b 100644 --- a/packages/sheets-formula-ui/src/locale/ko-KR.ts +++ b/packages/sheets-formula-ui/src/locale/ko-KR.ts @@ -75,6 +75,15 @@ const locale: typeof enUS = { allFunctions: '모든 함수', syntax: '구문', }, + mobileFunction: { + title: '함수 삽입', + recommended: '추천', + recent: '최근', + details: '세부 정보', + insert: '함수 삽입', + empty: '함수가 없습니다', + close: '닫기', + }, operation: { copyFormulaOnly: '수식만 복사', pasteFormula: '수식 붙여넣기', diff --git a/packages/sheets-formula-ui/src/locale/pl-PL.ts b/packages/sheets-formula-ui/src/locale/pl-PL.ts index deddbbded4..99567c91e6 100644 --- a/packages/sheets-formula-ui/src/locale/pl-PL.ts +++ b/packages/sheets-formula-ui/src/locale/pl-PL.ts @@ -75,6 +75,15 @@ const locale: typeof enUS = { allFunctions: 'Wszystkie funkcje', syntax: 'SKŁADNIA', }, + mobileFunction: { + title: 'Wstaw funkcję', + recommended: 'Polecane', + recent: 'Ostatnie', + details: 'Szczegóły', + insert: 'Wstaw funkcję', + empty: 'Brak funkcji', + close: 'Zamknij', + }, operation: { copyFormulaOnly: 'Kopiuj tylko formułę', pasteFormula: 'Wklej formułę', diff --git a/packages/sheets-formula-ui/src/locale/pt-BR.ts b/packages/sheets-formula-ui/src/locale/pt-BR.ts index dc3352a1a2..9cb29c653b 100644 --- a/packages/sheets-formula-ui/src/locale/pt-BR.ts +++ b/packages/sheets-formula-ui/src/locale/pt-BR.ts @@ -75,6 +75,15 @@ const locale: typeof enUS = { allFunctions: 'Todas as Funções', syntax: 'SINTAXE', }, + mobileFunction: { + title: 'Inserir função', + recommended: 'Recomendadas', + recent: 'Recentes', + details: 'Detalhes', + insert: 'Inserir função', + empty: 'Nenhuma função', + close: 'Fechar', + }, operation: { copyFormulaOnly: 'Copiar Apenas Fórmula', pasteFormula: 'Colar Fórmula', diff --git a/packages/sheets-formula-ui/src/locale/ru-RU.ts b/packages/sheets-formula-ui/src/locale/ru-RU.ts index 8a14503b73..0839c2e554 100644 --- a/packages/sheets-formula-ui/src/locale/ru-RU.ts +++ b/packages/sheets-formula-ui/src/locale/ru-RU.ts @@ -75,6 +75,15 @@ const locale: typeof enUS = { allFunctions: 'Все функции', syntax: 'СИНТАКСИС', }, + mobileFunction: { + title: 'Вставить функцию', + recommended: 'Рекомендуемые', + recent: 'Недавние', + details: 'Подробнее', + insert: 'Вставить функцию', + empty: 'Нет функций', + close: 'Закрыть', + }, operation: { copyFormulaOnly: 'Копировать только формулу', pasteFormula: 'Вставить Формулу', diff --git a/packages/sheets-formula-ui/src/locale/sk-SK.ts b/packages/sheets-formula-ui/src/locale/sk-SK.ts index 3ad51aaaca..43fea66d33 100644 --- a/packages/sheets-formula-ui/src/locale/sk-SK.ts +++ b/packages/sheets-formula-ui/src/locale/sk-SK.ts @@ -75,6 +75,15 @@ const locale: typeof enUS = { allFunctions: 'Všetky funkcie', syntax: 'SYNTAX', }, + mobileFunction: { + title: 'Vložiť funkciu', + recommended: 'Odporúčané', + recent: 'Nedávne', + details: 'Podrobnosti', + insert: 'Vložiť funkciu', + empty: 'Žiadne funkcie', + close: 'Zavrieť', + }, operation: { copyFormulaOnly: 'Kopírovať iba vzorec', pasteFormula: 'Prilepiť vzorec', diff --git a/packages/sheets-formula-ui/src/locale/vi-VN.ts b/packages/sheets-formula-ui/src/locale/vi-VN.ts index e1e20b5ff0..a734328e86 100644 --- a/packages/sheets-formula-ui/src/locale/vi-VN.ts +++ b/packages/sheets-formula-ui/src/locale/vi-VN.ts @@ -78,6 +78,15 @@ const locale: typeof enUS = { syntax: 'Cú pháp', }, + mobileFunction: { + title: 'Chèn hàm', + recommended: 'Đề xuất', + recent: 'Gần đây', + details: 'Chi tiết', + insert: 'Chèn hàm', + empty: 'Không có hàm', + close: 'Đóng', + }, operation: { copyFormulaOnly: 'Chỉ sao chép công thức', pasteFormula: 'Chỉ dán công thức', diff --git a/packages/sheets-formula-ui/src/locale/zh-CN.ts b/packages/sheets-formula-ui/src/locale/zh-CN.ts index f660603645..a590cf7bea 100644 --- a/packages/sheets-formula-ui/src/locale/zh-CN.ts +++ b/packages/sheets-formula-ui/src/locale/zh-CN.ts @@ -78,6 +78,15 @@ const locale: typeof enUS = { syntax: '语法', }, + mobileFunction: { + title: '插入函数', + recommended: '推荐', + recent: '最近', + details: '详情', + insert: '插入函数', + empty: '暂无函数', + close: '关闭', + }, operation: { copyFormulaOnly: '仅复制公式', pasteFormula: '仅粘贴公式', diff --git a/packages/sheets-formula-ui/src/locale/zh-HK.ts b/packages/sheets-formula-ui/src/locale/zh-HK.ts index 52e4d2d7ea..5efb7fcd9b 100644 --- a/packages/sheets-formula-ui/src/locale/zh-HK.ts +++ b/packages/sheets-formula-ui/src/locale/zh-HK.ts @@ -78,6 +78,15 @@ const locale: typeof enUS = { syntax: '語法', }, + mobileFunction: { + title: '插入函數', + recommended: '推薦', + recent: '最近', + details: '詳細資料', + insert: '插入函數', + empty: '暫無函數', + close: '關閉', + }, operation: { copyFormulaOnly: '僅複製公式', pasteFormula: '僅貼上公式', diff --git a/packages/sheets-formula-ui/src/locale/zh-TW.ts b/packages/sheets-formula-ui/src/locale/zh-TW.ts index 3ad35b1647..66d31ec1ab 100644 --- a/packages/sheets-formula-ui/src/locale/zh-TW.ts +++ b/packages/sheets-formula-ui/src/locale/zh-TW.ts @@ -78,6 +78,15 @@ const locale: typeof enUS = { syntax: '語法', }, + mobileFunction: { + title: '插入函數', + recommended: '建議', + recent: '最近', + details: '詳細資料', + insert: '插入函數', + empty: '沒有函數', + close: '關閉', + }, operation: { copyFormulaOnly: '僅複製公式', pasteFormula: '僅貼上公式', diff --git a/packages/sheets-formula-ui/src/views/formula-editor/hooks/__tests__/formula-selection-business.spec.ts b/packages/sheets-formula-ui/src/views/formula-editor/hooks/__tests__/formula-selection-business.spec.ts index b1ede89f50..cebc73af4a 100644 --- a/packages/sheets-formula-ui/src/views/formula-editor/hooks/__tests__/formula-selection-business.spec.ts +++ b/packages/sheets-formula-ui/src/views/formula-editor/hooks/__tests__/formula-selection-business.spec.ts @@ -32,6 +32,7 @@ import { isEventTargetInSameFormulaEmbedInteractionBoundary, registerFormulaEditorRuntimePortal, } from '../../formula-embed-integration.service'; +import { buildFormulaFunctionInsertion, buildFormulaOperatorInsertion } from '../../index'; import { focusFormulaEditor, hasActiveFormulaEmbedInteraction, @@ -44,6 +45,7 @@ import { resolveFormulaSelectionCursorIndex, resolveFormulaSelectionDataStream, resolveFormulaSelectionWorkbook, + shouldAddFormulaReference, shouldSkipReferenceEditingByPointer, } from '../use-formula-selection'; import { calcHighlightRanges, createFormulaHighlightBody } from '../use-highlight'; @@ -230,6 +232,13 @@ describe('formula selection update helpers', () => { })).toBe(true); }); + it('lets the mobile fx bar own formula selection when the canvas leaves no focused editor', () => { + expect(isFormulaEditorInteractionOwner(null, DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY, { + fxBarFocused: true, + allowMissingFocus: true, + })).toBe(true); + }); + it('does not let the hidden normal editor own the fx bar outside an fx formula selection session', () => { expect(isFormulaEditorInteractionOwner(DOCS_NORMAL_EDITOR_UNIT_ID_KEY, DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY, { fxBarFocused: false, @@ -309,6 +318,42 @@ describe('formula selection update helpers', () => { expect(resolveFormulaSelectingIntent(false, false)).toBe(FormulaSelectingType.NOT_SELECT); }); + it('only arms mobile reference picking after formula delimiters and operators', () => { + expect(shouldAddFormulaReference('=', 1)).toBe(true); + expect(shouldAddFormulaReference('=SUM(', 5)).toBe(true); + expect(shouldAddFormulaReference('=SUM(A1,', 8)).toBe(true); + expect(shouldAddFormulaReference('=A1+', 4)).toBe(true); + expect(shouldAddFormulaReference('=Sheet1!', 8)).toBe(true); + expect(shouldAddFormulaReference('=SUM', 4)).toBe(false); + expect(shouldAddFormulaReference('=A1', 3)).toBe(false); + }); + + it('inserts a mobile function at the caret and leaves the caret inside its brackets', () => { + expect(buildFormulaFunctionInsertion('=', { startOffset: 1, endOffset: 1 }, 'SUM')).toEqual({ + text: '=SUM()', + caretOffset: 5, + }); + expect(buildFormulaFunctionInsertion('=A1+', { startOffset: 4, endOffset: 4 }, 'AVERAGE')).toEqual({ + text: '=A1+AVERAGE()', + caretOffset: 12, + }); + expect(buildFormulaFunctionInsertion('plain', { startOffset: 5, endOffset: 5 }, 'COUNT')).toEqual({ + text: '=plainCOUNT()', + caretOffset: 12, + }); + }); + + it('inserts a mobile formula operator at the caret or over selected text', () => { + expect(buildFormulaOperatorInsertion('=A1B1', { startOffset: 3, endOffset: 3 }, '+')).toEqual({ + text: '=A1+B1', + caretOffset: 4, + }); + expect(buildFormulaOperatorInsertion('=A1+B1', { startOffset: 3, endOffset: 4 }, '*')).toEqual({ + text: '=A1*B1', + caretOffset: 4, + }); + }); + it('reorders the active selection into the formula reference being edited and keeps ctrl-added ranges separate', () => { const selections = [range(0, 0), range(1, 1), range(2, 2)]; diff --git a/packages/sheets-formula-ui/src/views/formula-editor/hooks/use-formula-selection.ts b/packages/sheets-formula-ui/src/views/formula-editor/hooks/use-formula-selection.ts index 7f994d3a00..6397080bd4 100644 --- a/packages/sheets-formula-ui/src/views/formula-editor/hooks/use-formula-selection.ts +++ b/packages/sheets-formula-ui/src/views/formula-editor/hooks/use-formula-selection.ts @@ -121,9 +121,20 @@ export function resolveFormulaSelectingIntent(adding: boolean, editing: boolean) return FormulaSelectingType.NOT_SELECT; } +export function shouldAddFormulaReference(dataStream: string, index: number): boolean { + const char = dataStream[index - 1]; + const nextChar = dataStream[index]; + + return Boolean( + char && + (matchRefDrawToken(char) || char === '!') && + (!nextChar || (isFormulaLexerToken(nextChar) && nextChar !== matchToken.OPEN_BRACKET)) + ); +} + // eslint-disable-next-line max-lines-per-function -export function useFormulaSelecting(opts: { editor?: Editor; editorId: string; isFocus: boolean; disableOnClick?: boolean; unitId: string; subUnitId: string }) { - const { editor, editorId, isFocus, disableOnClick, unitId, subUnitId } = opts; +export function useFormulaSelecting(opts: { editor?: Editor; editorId: string; isFocus: boolean; disableOnClick?: boolean; resetSignal?: number; unitId: string; subUnitId: string }) { + const { editor, editorId, isFocus, disableOnClick, resetSignal, unitId, subUnitId } = opts; const renderManagerService = useDependency(IRenderManagerService); const univerInstanceService = useDependency(IUniverInstanceService); const sheetRenderer = renderManagerService.getRenderUnitById(unitId); @@ -179,10 +190,8 @@ export function useFormulaSelecting(opts: { editor?: Editor; editorId: string; i return node; }); - const char = dataStream[index - 1]; - const nextChar = dataStream[index]; const focusingNode = nodes.find((node) => typeof node === 'object' && node.nodeType === sequenceNodeType.REFERENCE && index === node.endIndex + 2) as unknown as (ISequenceNode & { range: IUnitRangeName }); - const adding = (char && matchRefDrawToken(char)) && (!nextChar || (isFormulaLexerToken(nextChar) && nextChar !== matchToken.OPEN_BRACKET)); + const adding = shouldAddFormulaReference(dataStream, index); const editing = Boolean(focusingNode); const selectingIntent = resolveFormulaSelectingIntent(Boolean(adding), editing); @@ -278,6 +287,12 @@ export function useFormulaSelecting(opts: { editor?: Editor; editorId: string; i } }, [isFocus, setIsSelecting]); + useEffect(() => { + if (resetSignal === undefined) return; + setIsSelecting(FormulaSelectingType.NOT_SELECT); + isDisabledByPointer.current = true; + }, [resetSignal, setIsSelecting]); + useEffect(() => { if (!disableOnClick) return; const sub = renderer?.mainComponent?.onPointerDown$.subscribeEvent(() => { diff --git a/packages/sheets-formula-ui/src/views/formula-editor/hooks/use-left-and-right-arrow.ts b/packages/sheets-formula-ui/src/views/formula-editor/hooks/use-left-and-right-arrow.ts index d786c33168..5353da90e6 100644 --- a/packages/sheets-formula-ui/src/views/formula-editor/hooks/use-left-and-right-arrow.ts +++ b/packages/sheets-formula-ui/src/views/formula-editor/hooks/use-left-and-right-arrow.ts @@ -35,6 +35,7 @@ export interface IFormulaEditorInteractionOwnerOptions { fxBarFocused?: boolean; formulaBarEditorId?: string; normalEditorId?: string; + allowMissingFocus?: boolean; } export function isFormulaEditorInteractionOwner( @@ -51,7 +52,10 @@ export function isFormulaEditorInteractionOwner( } return editorId === (options.formulaBarEditorId ?? DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY) && - focusEditorId === (options.normalEditorId ?? DOCS_NORMAL_EDITOR_UNIT_ID_KEY); + ( + focusEditorId === (options.normalEditorId ?? DOCS_NORMAL_EDITOR_UNIT_ID_KEY) || + Boolean(options.allowMissingFocus && !focusEditorId) + ); } export const useLeftAndRightArrow = ( diff --git a/packages/sheets-formula-ui/src/views/formula-editor/hooks/use-refactor-effect.ts b/packages/sheets-formula-ui/src/views/formula-editor/hooks/use-refactor-effect.ts index b00ee29b94..1e38e05e59 100644 --- a/packages/sheets-formula-ui/src/views/formula-editor/hooks/use-refactor-effect.ts +++ b/packages/sheets-formula-ui/src/views/formula-editor/hooks/use-refactor-effect.ts @@ -22,7 +22,7 @@ import { IContextMenuService, useDependency, useObservable } from '@univerjs/ui' import { useEffect, useLayoutEffect, useMemo } from 'react'; import { RefSelectionsRenderService } from '../../../services/render-services/ref-selections.render.service'; -export const useRefactorEffect = (isNeed: boolean, selecting: boolean | number, unitId: string, editorId: string, disableContextMenu = true) => { +export const useRefactorEffect = (isNeed: boolean, selecting: boolean | number, unitId: string, editorId: string, disableContextMenu = true, keepRefSelectionsEnabled = false) => { const renderManagerService = useDependency(IRenderManagerService); const contextService = useDependency(IContextService); const contextMenuService = useDependency(IContextMenuService); @@ -51,15 +51,27 @@ export const useRefactorEffect = (isNeed: boolean, selecting: boolean | number, useLayoutEffect(() => { if (isNeed && Boolean(selecting)) { + let active = true; const d1 = refSelectionsRenderService?.enableSelectionChanging(); contextService.setContextValue(REF_SELECTIONS_ENABLED, true); + const subscription = keepRefSelectionsEnabled + ? contextService.subscribeContextValue$(REF_SELECTIONS_ENABLED).subscribe((enabled) => { + if (!enabled) { + queueMicrotask(() => { + if (active) contextService.setContextValue(REF_SELECTIONS_ENABLED, true); + }); + } + }) + : null; return () => { + active = false; + subscription?.unsubscribe(); contextService.setContextValue(REF_SELECTIONS_ENABLED, false); d1?.dispose(); }; } - }, [contextService, isNeed, refSelectionsRenderService, selecting]); + }, [contextService, isNeed, keepRefSelectionsEnabled, refSelectionsRenderService, selecting]); // reset setSkipLastEnabled useEffect(() => { diff --git a/packages/sheets-formula-ui/src/views/formula-editor/hooks/use-sheet-selection-change.ts b/packages/sheets-formula-ui/src/views/formula-editor/hooks/use-sheet-selection-change.ts index 1097bc6566..1f9549f026 100644 --- a/packages/sheets-formula-ui/src/views/formula-editor/hooks/use-sheet-selection-change.ts +++ b/packages/sheets-formula-ui/src/views/formula-editor/hooks/use-sheet-selection-change.ts @@ -27,7 +27,6 @@ import { ICommandService, IContextService, IUniverInstanceService, - noop, Rectangle, ThemeService, UniverInstanceType, @@ -263,7 +262,8 @@ export const useSheetSelectionChange = ( isSupportAcrossSheet: boolean, listenSelectionSet: boolean, editor?: Editor, - handleRangeChange: ((refString: string, offset: number, isEnd: boolean, isModify?: boolean) => void) = noop as any + handleRangeChange: ((refString: string, offset: number, isEnd: boolean, isModify?: boolean) => void) = () => {}, + allowMissingEditorFocus = false ) => { const renderManagerService = useDependency(IRenderManagerService); const univerInstanceService = useDependency(IUniverInstanceService); @@ -290,6 +290,7 @@ export const useSheetSelectionChange = ( const onSelectionsChange = useEvent((selections: IRange[], isEnd: boolean, isCtrlAddMode?: boolean) => { if (!editor || !isFormulaEditorInteractionOwner(editorService.getFocusId(), editor.getEditorId(), { fxBarFocused: contextService.getContextValue(FOCUSING_FX_BAR_EDITOR), + allowMissingFocus: allowMissingEditorFocus, })) { return; } @@ -435,6 +436,14 @@ export const useSheetSelectionChange = ( useEffect(() => { if (refSelectionsRenderService && isNeed) { + const isInteractionOwner = () => Boolean(editor && isFormulaEditorInteractionOwner( + editorService.getFocusId(), + editor.getEditorId(), + { + fxBarFocused: contextService.getContextValue(FOCUSING_FX_BAR_EDITOR), + allowMissingFocus: allowMissingEditorFocus, + } + )); const initialSelectionsCount = getInitialFormulaReferenceSelectionCount( refSelectionsRenderService.getSelectionDataWithStyle().length, getRefSelections().length, @@ -461,12 +470,15 @@ export const useSheetSelectionChange = ( const disposableCollection = new DisposableCollection(); disposableCollection.add(refSelectionsRenderService.selectionMoveStart$.subscribe((selections) => { + if (!isInteractionOwner()) return; handleSelectionsChange(selections, false); })); disposableCollection.add(refSelectionsRenderService.selectionMoving$.subscribe((selections) => { + if (!isInteractionOwner()) return; handleSelectionsChange(selections, false); })); disposableCollection.add(refSelectionsRenderService.selectionMoveEnd$.subscribe((selections) => { + if (!isInteractionOwner()) return; handleSelectionsChange(selections, true, { initial: isInitialMoveEnd }); isInitialMoveEnd = false; })); @@ -475,7 +487,7 @@ export const useSheetSelectionChange = ( disposableCollection.dispose(); }; } - }, [getRefSelections, isNeed, onSelectionsChange, refSelectionsRenderService, refSelectionsService]); + }, [allowMissingEditorFocus, contextService, editor, editorService, getRefSelections, isNeed, onSelectionsChange, refSelectionsRenderService, refSelectionsService]); useEffect(() => { if (isFocus && refSelectionsRenderService && editor) { @@ -535,6 +547,7 @@ export const useSheetSelectionChange = ( } if (!editor || !isFormulaEditorInteractionOwner(editorService.getFocusId(), editor.getEditorId(), { fxBarFocused: contextService.getContextValue(FOCUSING_FX_BAR_EDITOR), + allowMissingFocus: allowMissingEditorFocus, })) { return; } diff --git a/packages/sheets-formula-ui/src/views/formula-editor/index.tsx b/packages/sheets-formula-ui/src/views/formula-editor/index.tsx index 22af8eab9e..6e9434bdf8 100644 --- a/packages/sheets-formula-ui/src/views/formula-editor/index.tsx +++ b/packages/sheets-formula-ui/src/views/formula-editor/index.tsx @@ -73,6 +73,7 @@ import { useSheetSelectionChange } from './hooks/use-sheet-selection-change'; import { useStateRef } from './hooks/use-state-ref'; import { useSwitchSheet } from './hooks/use-switch-sheet'; import { useVerify } from './hooks/use-verify'; +import { MobileFunctionPanel } from './mobile-function-panel/MobileFunctionPanel'; import { SearchFunction } from './search-function/SearchFunction'; import { getFormulaText } from './utils/get-formula-text'; @@ -108,6 +109,11 @@ export interface IFormulaEditorProps { backgroundColor?: string; fontSize?: number; }; + mobile?: boolean; + mobileFxRequest?: number; + mobileFunctionPanelRequest?: number; + mobileOperatorRequest?: { id: number; value: string }; + onMobileFormulaActiveChange?: (active: boolean) => void; } export interface IFormulaEditorRef { @@ -148,6 +154,40 @@ export function syncCounterpartFormulaEditorSelection( editorService.getEditor(syncEditorId)?.setSelectionRanges(selections, false); } +export function buildFormulaFunctionInsertion( + formulaText: string, + selection: Pick | undefined, + functionName: string +): { text: string; caretOffset: number } { + const normalizedText = formulaText.replace(/\r?\n$/, ''); + const text = normalizedText.startsWith('=') ? normalizedText : `=${normalizedText}`; + const selectionAdjustment = normalizedText.startsWith('=') ? 0 : 1; + const startOffset = Math.max(1, Math.min(text.length, (selection?.startOffset ?? text.length) + selectionAdjustment)); + const endOffset = Math.max(startOffset, Math.min(text.length, (selection?.endOffset ?? startOffset) + selectionAdjustment)); + + return { + text: `${text.slice(0, startOffset)}${functionName}()${text.slice(endOffset)}`, + caretOffset: startOffset + functionName.length + 1, + }; +} + +export function buildFormulaOperatorInsertion( + formulaText: string, + selection: Pick | undefined, + value: string +): { text: string; caretOffset: number } { + const normalizedText = formulaText.replace(/\r?\n$/, ''); + const text = normalizedText.startsWith('=') ? normalizedText : `=${normalizedText}`; + const selectionAdjustment = normalizedText.startsWith('=') ? 0 : 1; + const startOffset = Math.max(1, Math.min(text.length, (selection?.startOffset ?? text.length) + selectionAdjustment)); + const endOffset = Math.max(startOffset, Math.min(text.length, (selection?.endOffset ?? startOffset) + selectionAdjustment)); + + return { + text: `${text.slice(0, startOffset)}${value}${text.slice(endOffset)}`, + caretOffset: startOffset + value.length, + }; +} + export const FormulaEditor = forwardRef((props: IFormulaEditorProps, ref: Ref) => { const { errorText, @@ -175,6 +215,11 @@ export const FormulaEditor = forwardRef((props: IFormulaEditorProps, ref: Ref(null); const editorRef = useRef(undefined); const [editor, setEditor] = useState(); @@ -212,7 +258,16 @@ export const FormulaEditor = forwardRef((props: IFormulaEditorProps, ref: Ref getFormulaText(formulaText), [formulaText]); const sequenceNodes = useMemo(() => getFormulaToken(formulaWithoutEqualSymbol), [formulaWithoutEqualSymbol, getFormulaToken]); - const { isSelecting, isSelectingRef } = useFormulaSelecting({ unitId, subUnitId, editor, editorId, isFocus, disableOnClick: disableSelectionOnClick }); + const [mobileFunctionPanelOpen, setMobileFunctionPanelOpen] = useState(false); + const { isSelecting, isSelectingRef } = useFormulaSelecting({ + unitId, + subUnitId, + editor, + editorId, + isFocus, + disableOnClick: disableSelectionOnClick, + resetSignal: mobile ? mobileFxRequest : undefined, + }); const highTextRef = useRef(''); const renderManagerService = useDependency(IRenderManagerService); const renderer = renderManagerService.getRenderUnitById(editorId); @@ -234,6 +289,12 @@ export const FormulaEditor = forwardRef((props: IFormulaEditorProps, ref: Ref { + if (mobile) { + onMobileFormulaActiveChange?.(formulaText.replace(/\r?\n$/, '').startsWith('=')); + } + }, [formulaText, mobile, onMobileFormulaActiveChange]); + useEffect(() => { if (!isFocus || !editor) { return undefined; @@ -261,6 +322,21 @@ export const FormulaEditor = forwardRef((props: IFormulaEditorProps, ref: Ref subscription.unsubscribe(); }, [editor, editorId, editorService, isFocus]); + const handledMobileFunctionPanelRequestRef = useRef(mobileFunctionPanelRequest); + useEffect(() => { + if ( + !mobile || + mobileFunctionPanelRequest === undefined || + mobileFunctionPanelRequest === handledMobileFunctionPanelRequestRef.current + ) { + return; + } + + handledMobileFunctionPanelRequestRef.current = mobileFunctionPanelRequest; + setMobileFunctionPanelOpen(true); + editor?.blur(); + }, [editor, mobile, mobileFunctionPanelRequest]); + const highlightDoc = useDocHight('='); const highlightSheet = useSheetHighlight(unitId, subUnitId); const highlight = useEvent((text: string, isNeedResetSelection: boolean = true, isEnd?: boolean, newSelections?: ITextRange[]) => { @@ -454,7 +530,7 @@ export const FormulaEditor = forwardRef((props: IFormulaEditorProps, ref: Ref { @@ -491,7 +567,8 @@ export const FormulaEditor = forwardRef((props: IFormulaEditorProps, ref: Ref { highlight(formulaTextRef.current, false, true); @@ -514,6 +591,67 @@ export const FormulaEditor = forwardRef((props: IFormulaEditorProps, ref: Ref { + setMobileFunctionPanelOpen(false); + requestAnimationFrame(() => focus()); + }; + + const applyMobileInsertion = useEvent((result: { text: string; caretOffset: number }) => { + if (!editor) return; + const selection = { + startOffset: result.caretOffset, + endOffset: result.caretOffset, + collapsed: true, + }; + + editor.replaceText(result.text, false); + editor.setSelectionRanges([selection], false); + + const counterpartEditorId = editorId === DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY + ? DOCS_NORMAL_EDITOR_UNIT_ID_KEY + : editorId === DOCS_NORMAL_EDITOR_UNIT_ID_KEY + ? DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY + : null; + if (counterpartEditorId) { + const counterpartEditor = editorService.getEditor(counterpartEditorId); + counterpartEditor?.replaceText(result.text, false); + counterpartEditor?.setSelectionRanges([selection], false); + } + + highlight(result.text, false, true, [selection]); + requestAnimationFrame(() => focus()); + }); + + const handledMobileOperatorRequestRef = useRef(mobileOperatorRequest?.id); + useEffect(() => { + if ( + !mobile || + !editor || + !mobileOperatorRequest?.value || + mobileOperatorRequest.id === handledMobileOperatorRequestRef.current + ) { + return; + } + + handledMobileOperatorRequestRef.current = mobileOperatorRequest.id; + const currentText = BuildTextUtils.transform.getPlainText(editor.getDocumentData().body?.dataStream ?? ''); + applyMobileInsertion(buildFormulaOperatorInsertion( + currentText, + editor.getSelectionRanges()?.[0], + mobileOperatorRequest.value + )); + }, [applyMobileInsertion, editor, mobile, mobileOperatorRequest]); + + const handleMobileFunctionInsert = (functionName: string) => { + if (!editor) return; + + const currentText = BuildTextUtils.transform.getPlainText(editor.getDocumentData().body?.dataStream ?? ''); + const currentSelection = editor.getSelectionRanges()?.[0]; + const result = buildFormulaFunctionInsertion(currentText, currentSelection, functionName); + applyMobileInsertion(result); + setMobileFunctionPanelOpen(false); + }; + const handleMouseUp = (event: React.MouseEvent) => { if (hasActiveFormulaEmbedInteraction(formulaEditorContainerRef.current)) { return; @@ -555,7 +693,7 @@ export const FormulaEditor = forwardRef((props: IFormulaEditorProps, ref: Ref )} - {(functionScreenTips && editor && formulaWithoutEqualSymbol !== '') && ( + {(!mobile && functionScreenTips && editor && formulaWithoutEqualSymbol !== '') && ( )} + ); }); diff --git a/packages/sheets-formula-ui/src/views/formula-editor/mobile-function-panel/MobileFunctionPanel.tsx b/packages/sheets-formula-ui/src/views/formula-editor/mobile-function-panel/MobileFunctionPanel.tsx new file mode 100644 index 0000000000..1b7248025d --- /dev/null +++ b/packages/sheets-formula-ui/src/views/formula-editor/mobile-function-panel/MobileFunctionPanel.tsx @@ -0,0 +1,344 @@ +/** + * 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 { IFunctionInfo, ISearchItem } from '@univerjs/engine-formula'; +import type { LocaleKey } from '../../../locale/types'; +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 { getFunctionTypeValues } from '../../../services/utils'; + +const RECENT_FUNCTIONS_KEY = 'univer-mobile-recent-formula-functions'; +const RECOMMENDED_FUNCTIONS = ['SUM', 'AVERAGE', 'COUNT', 'MAX', 'MIN', 'IF', 'XLOOKUP', 'ROUND']; + +type MobileFunctionCategory = 'recommended' | 'recent' | 'all' | number; + +function readRecentFunctions(): string[] { + try { + return JSON.parse(globalThis.localStorage?.getItem(RECENT_FUNCTIONS_KEY) ?? '[]'); + } catch { + return []; + } +} + +function rememberFunction(name: string): void { + try { + const recent = [name, ...readRecentFunctions().filter((item) => item !== name)].slice(0, 8); + globalThis.localStorage?.setItem(RECENT_FUNCTIONS_KEY, JSON.stringify(recent)); + } catch { + // Storage is optional in embedded webviews. + } +} + +export function MobileFunctionPanel(props: { + open: boolean; + onClose: () => void; + onInsert: (name: string) => void; +}) { + const { open, onClose, onInsert } = props; + const descriptionService = useDependency(IDescriptionService); + const localeService = useDependency(LocaleService); + const copy = { + title: localeService.t('sheets-formula-ui.mobileFunction.title'), + search: localeService.t('sheets-formula-ui.moreFunctions.searchFunctionPlaceholder'), + recommended: localeService.t('sheets-formula-ui.mobileFunction.recommended'), + recent: localeService.t('sheets-formula-ui.mobileFunction.recent'), + all: localeService.t('sheets-formula-ui.moreFunctions.allFunctions'), + details: localeService.t('sheets-formula-ui.mobileFunction.details'), + back: localeService.t('sheets-formula-ui.moreFunctions.prev'), + insert: localeService.t('sheets-formula-ui.mobileFunction.insert'), + empty: localeService.t('sheets-formula-ui.mobileFunction.empty'), + syntax: localeService.t('sheets-formula-ui.moreFunctions.syntax'), + close: localeService.t('sheets-formula-ui.mobileFunction.close'), + }; + const [query, setQuery] = useState(''); + const [category, setCategory] = useState('recommended'); + const [details, setDetails] = useState(null); + const [recentVersion, setRecentVersion] = useState(0); + + useEffect(() => { + if (!open) return; + setQuery(''); + setCategory('recommended'); + setDetails(null); + }, [open]); + + const categories = useMemo(() => { + if (!open) return []; + + return [ + { label: copy.recommended, value: 'recommended' as const }, + { label: copy.recent, value: 'recent' as const }, + { label: copy.all, value: 'all' as const }, + ...getFunctionTypeValues(localeService, false).map((item) => ({ label: item.label, value: Number(item.value) })), + ].filter((item) => typeof item.value !== 'number' || ( + item.value !== FunctionType.DefinedName && + item.value !== FunctionType.Table && + descriptionService.getSearchListByType(item.value).length > 0 + )); + }, [copy.all, copy.recent, copy.recommended, descriptionService, localeService, open]); + + const functions = useMemo(() => { + if (!open) return []; + if (query.trim()) return descriptionService.getSearchListByName(query).slice(0, 60); + + if (category === 'recommended') { + return RECOMMENDED_FUNCTIONS + .map((name) => descriptionService.getFunctionInfo(name)) + .filter((item): item is IFunctionInfo => Boolean(item)) + .map((item) => ({ name: item.functionName, desc: item.abstract })); + } + + if (category === 'recent') { + return readRecentFunctions() + .map((name) => descriptionService.getFunctionInfo(name)) + .filter((item): item is IFunctionInfo => Boolean(item)) + .map((item) => ({ name: item.functionName, desc: item.abstract })); + } + + return descriptionService.getSearchListByType(category === 'all' ? -1 : category).slice(0, 100); + }, [category, descriptionService, open, query, recentVersion]); + + const handleInsert = (name: string) => { + rememberFunction(name); + setRecentVersion((value) => value + 1); + onInsert(name); + }; + + if (!open) return null; + + return ( +
event.stopPropagation()} + > +
+
+
+
+
+ {details && ( + + )} +

+ {details?.functionName ?? copy.title} +

+ +
+ + {details + ? ( +
+
+

+ {details.description} +

+
+ {copy.syntax} +
+ + {`${details.functionName}(${details.functionParameter.map((item) => item.name).join(', ')})`} + +
+ +
+ ) + : ( + <> +
+ setQuery(event.target.value)} + /> +
+ {!query && ( +
+ {categories.map((item) => ( + + ))} +
+ )} +
+ {functions.length === 0 && ( +
+ {copy.empty} +
+ )} + {functions.map((item) => ( +
+ + +
+ ))} +
+ + )} +
+
+ ); +} diff --git a/packages/sheets-formula-ui/src/views/formula-editor/search-function/SearchFunction.tsx b/packages/sheets-formula-ui/src/views/formula-editor/search-function/SearchFunction.tsx index 94fb7ef040..94e811f87d 100644 --- a/packages/sheets-formula-ui/src/views/formula-editor/search-function/SearchFunction.tsx +++ b/packages/sheets-formula-ui/src/views/formula-editor/search-function/SearchFunction.tsx @@ -35,10 +35,11 @@ interface ISearchFunctionProps { onChange?: (functionName: string) => void; editor: Editor; onClose?: () => void; + mobile?: boolean; }; export const SearchFunction = forwardRef(SearchFunctionFactory); function SearchFunctionFactory(props: ISearchFunctionProps, ref: any) { - const { isFocus, sequenceNodes, onSelect, editor, onClose = noop } = props; + const { isFocus, sequenceNodes, onSelect, editor, onClose = noop, mobile = false } = props; const editorId = editor.getEditorId(); const shortcutService = useDependency(IShortcutService); const commandService = useDependency(ICommandService); @@ -196,12 +197,12 @@ function SearchFunctionFactory(props: ISearchFunctionProps, ref: any) { } }} data-u-comp="sheets-formula-editor" + data-presentation={mobile ? 'mobile' : 'desktop'} className={clsx(` - univer-m-0 univer-box-border univer-max-h-[400px] univer-w-[250px] univer-list-none - univer-overflow-y-auto univer-rounded-lg univer-bg-gray-0 univer-p-2 univer-leading-5 univer-shadow-md - univer-outline-none + univer-m-0 univer-box-border univer-list-none univer-overflow-y-auto univer-rounded-lg + univer-bg-gray-0 univer-p-2 univer-leading-5 univer-shadow-md univer-outline-none dark:!univer-bg-gray-900 - `, borderClassName, scrollbarClassName)} + `, mobile ? 'univer-max-h-[38dvh] univer-w-[calc(100vw-16px)]' : 'univer-max-h-[400px] univer-w-[250px]', borderClassName, scrollbarClassName)} > {searchList.map((item, index) => (
  • handleLiMouseEnter(index)} diff --git a/packages/sheets-formula-ui/src/views/range-selector/MobileRangeSelectorDialog.tsx b/packages/sheets-formula-ui/src/views/range-selector/MobileRangeSelectorDialog.tsx new file mode 100644 index 0000000000..a8f855e019 --- /dev/null +++ b/packages/sheets-formula-ui/src/views/range-selector/MobileRangeSelectorDialog.tsx @@ -0,0 +1,90 @@ +/** + * 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 { MobileDrawerSnap } from '@univerjs/ui'; +import type { ReactNode } from 'react'; +import { ActionRow, Button } from '@univerjs/design'; +import { MobileDrawer } from '@univerjs/ui'; + +interface IMobileRangeSelectorDialogProps { + visible: boolean; + snap: MobileDrawerSnap; + title: string; + cancelText: string; + confirmText: string; + children: ReactNode; + onSnapChange: (snap: MobileDrawerSnap) => void; + onClose: () => void; + onConfirm: () => void; +} + +export function MobileRangeSelectorDialog(props: IMobileRangeSelectorDialogProps) { + const { + visible, + snap, + title, + cancelText, + confirmText, + children, + onSnapChange, + onClose, + onConfirm, + } = props; + + if (!visible) { + return null; + } + + return ( +
    + + {title} + + )} + footer={( +
    + + + + +
    + )} + > + {children} +
    +
    + ); +} diff --git a/packages/sheets-formula-ui/src/views/range-selector/__tests__/GlobalRangeSelector.spec.tsx b/packages/sheets-formula-ui/src/views/range-selector/__tests__/GlobalRangeSelector.spec.tsx index c99d7c8e23..9c01dff04d 100644 --- a/packages/sheets-formula-ui/src/views/range-selector/__tests__/GlobalRangeSelector.spec.tsx +++ b/packages/sheets-formula-ui/src/views/range-selector/__tests__/GlobalRangeSelector.spec.tsx @@ -32,10 +32,10 @@ import { IEditorService } from '@univerjs/docs-ui'; import { IDescriptionService, LexerTreeBuilder } from '@univerjs/engine-formula'; import { SetSelectionsOperation, SheetsSelectionsService } from '@univerjs/sheets'; import { IMarkSelectionService } from '@univerjs/sheets-ui'; -import { RediContext } from '@univerjs/ui'; +import { IDialogService, RediContext } from '@univerjs/ui'; import { act, createRef } from 'react'; import { createRoot } from 'react-dom/client'; -import { Subject } from 'rxjs'; +import { of, Subject } from 'rxjs'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { GlobalRangeSelectorService } from '../../../services/range-selector.service'; import { GlobalRangeSelector } from '../Global'; @@ -82,6 +82,20 @@ class TestCommandService { } } +class TestDialogService { + open(): IDisposable { + return { dispose: () => {} }; + } + + close(): void {} + + closeAll(): void {} + + getDialogs$() { + return of([]); + } +} + class TestEditorService { readonly blur$ = new Subject(); readonly focus$ = new Subject<{ unitId: string }>(); @@ -157,6 +171,7 @@ function createGlobalRangeSelectorTestBed() { injector.add([IMarkSelectionService, { useClass: TestMarkSelectionService as never }]); injector.add([SheetsSelectionsService, { useClass: TestSheetsSelectionsService as never }]); injector.add([IUniverInstanceService, { useClass: TestUniverInstanceService as never }]); + injector.add([IDialogService, { useClass: TestDialogService }]); injector.add([GlobalRangeSelectorService]); injector.get(LocaleService).load({ diff --git a/packages/sheets-formula-ui/src/views/range-selector/__tests__/RangeSelectorDialog.spec.tsx b/packages/sheets-formula-ui/src/views/range-selector/__tests__/RangeSelectorDialog.spec.tsx index e25cf74e94..196ab3553e 100644 --- a/packages/sheets-formula-ui/src/views/range-selector/__tests__/RangeSelectorDialog.spec.tsx +++ b/packages/sheets-formula-ui/src/views/range-selector/__tests__/RangeSelectorDialog.spec.tsx @@ -477,4 +477,65 @@ describe('RangeSelectorDialog', () => { ]]); expect(RangeDialogState.closed).toBe(0); }); + + it('renders a compact mobile range picker and keeps the canvas area unmasked', async () => { + const injector = createRangeDialogTestBed(); + act(() => { + root.render( + + RangeDialogState.confirmed.push(ranges)} + onClose={() => { + RangeDialogState.closed += 1; + }} + /> + + ); + }); + + const dialog = document.body.querySelector('[role="dialog"]'); + const picker = dialog?.parentElement; + expect(picker).toBeTruthy(); + expect(picker?.className).toContain('univer-pointer-events-none'); + expect(dialog?.className).toContain('univer-pointer-events-auto'); + const input = picker?.querySelector('input'); + expect(input).toBeInstanceOf(HTMLInputElement); + if (!(input instanceof HTMLInputElement)) { + throw new TypeError('Mobile range selector input was not rendered.'); + } + expect(input.value).toBe('Sheet1!A1:B2'); + + await emitSelection(injector, { + startRow: 3, + endRow: 6, + startColumn: 0, + endColumn: 2, + rangeType: RANGE_TYPE.NORMAL, + }); + + expect(input.value).toBe('A4:C7'); + await clickButton('Confirm'); + expect(RangeDialogState.confirmed[0][0].range).toEqual(expect.objectContaining({ + startRow: 3, + endRow: 6, + startColumn: 0, + endColumn: 2, + })); + }); }); diff --git a/packages/sheets-formula-ui/src/views/range-selector/index.tsx b/packages/sheets-formula-ui/src/views/range-selector/index.tsx index 8be3600338..be7a5d15ef 100644 --- a/packages/sheets-formula-ui/src/views/range-selector/index.tsx +++ b/packages/sheets-formula-ui/src/views/range-selector/index.tsx @@ -17,10 +17,11 @@ import type { IUnitRangeName, Nullable } from '@univerjs/core'; import type { Editor, IRichTextEditorProps } from '@univerjs/docs-ui'; import type { ISelectionWithStyle, ISetSelectionsOperationParams } from '@univerjs/sheets'; +import type { MobileDrawerSnap } from '@univerjs/ui'; import type { RefObject } from 'react'; import type { LocaleKey } from '../../locale/types'; import { ICommandService, LocaleService, RichTextBuilder } from '@univerjs/core'; -import { Button, clsx, Dialog, Input, scrollbarClassName, Tooltip } from '@univerjs/design'; +import { Button, clsx, ConfigContext, Dialog, Input, scrollbarClassName, Tooltip } from '@univerjs/design'; import { IEditorService, RichTextEditor } from '@univerjs/docs-ui'; import { deserializeRangeWithSheet, @@ -33,10 +34,11 @@ import { import { DeleteIcon, IncreaseIcon, SelectRangeIcon } from '@univerjs/icons'; import { SetSelectionsOperation } from '@univerjs/sheets'; import { useDependency, useEvent } from '@univerjs/ui'; -import { useEffect, useRef, useState } from 'react'; +import { useContext, useEffect, useRef, useState } from 'react'; import { useStateRef } from '../formula-editor/hooks/use-state-ref'; import { useRangesHighlight } from './hooks/use-ranges-highlight'; import { useRangeSelectorSelectionChange } from './hooks/use-selection-change'; +import { MobileRangeSelectorDialog } from './MobileRangeSelectorDialog'; import { rangePreProcess } from './utils/range-pre-process'; import { verifyRange } from './utils/verify-range'; @@ -79,6 +81,7 @@ export interface IRangeSelectorDialogProps { onConfirm: (ranges: IUnitRangeName[]) => void; onClose: () => void; onShowBySelection?: (ranges: IUnitRangeName[]) => boolean; + mobile?: boolean; } export function RangeSelectorDialog(props: IRangeSelectorDialogProps) { @@ -93,11 +96,13 @@ export function RangeSelectorDialog(props: IRangeSelectorDialogProps) { onConfirm, onClose, onShowBySelection, + mobile = false, } = props; const localeService = useDependency(LocaleService); const lexerTreeBuilder = useDependency(LexerTreeBuilder); const [ranges, setRanges] = useState([]); const [focusIndex, setFocusIndex] = useState(0); + const [drawerSnap, setDrawerSnap] = useState('compact'); const scrollbarRef = useRef(null); useEffect(() => { @@ -165,6 +170,86 @@ export function RangeSelectorDialog(props: IRangeSelectorDialogProps) { }, }); + const confirmRanges = () => { + onConfirm( + ranges + .filter((text) => { + const nodes = lexerTreeBuilder.sequenceNodesBuilder(text); + return nodes && nodes.length === 1 && typeof nodes[0] !== 'string' && nodes[0].nodeType === sequenceNodeType.REFERENCE; + }) + .map((text) => deserializeRangeWithSheet(text)) + .map((unitRange) => ({ ...unitRange, range: rangePreProcess(unitRange.range) })) + ); + }; + + const rangeInputs = (mobileLayout = false) => ( +
    + {ranges.map((text, index) => ( +
    + ('sheets-formula-ui.rangeSelector.placeHolder')} + onFocus={() => setFocusIndex(index)} + value={text} + onChange={(value) => handleRangeInput(index, value)} + /> + {ranges.length > 1 && ( + + )} +
    + ))} + {ranges.length < maxRangeCount && ( + + )} +
    + ); + + if (mobile) { + return ( + ('sheets-formula-ui.rangeSelector.title')} + cancelText={localeService.t('sheets-formula-ui.rangeSelector.cancel')} + confirmText={localeService.t('sheets-formula-ui.rangeSelector.confirm')} + onSnapChange={setDrawerSnap} + onClose={onClose} + onConfirm={confirmRanges} + > + {rangeInputs(true)} + + ); + } + return ( {localeService.t('sheets-formula-ui.rangeSelector.cancel')} @@ -195,38 +271,7 @@ export function RangeSelectorDialog(props: IRangeSelectorDialogProps) { )} onClose={onClose} > -
    - {ranges.map((text, index) => ( -
    - ('sheets-formula-ui.rangeSelector.placeHolder')} - onFocus={() => setFocusIndex(index)} - value={text} - onChange={(value) => handleRangeInput(index, value)} - /> - {ranges.length > 1 && ( - handleRangeRemove(index)} - /> - )} -
    - ))} - {ranges.length < maxRangeCount && ( -
    - -
    - )} -
    + {rangeInputs()}
    ); } @@ -243,6 +288,7 @@ export function stringifyRanges(ranges: IUnitRangeName[]) { export function RangeSelector(props: IRangeSelectorProps) { const [editor, setEditor] = useState(null); + const { mobile = false } = useContext(ConfigContext); const { onVerify, selectorRef, @@ -365,6 +411,7 @@ export function RangeSelector(props: IRangeSelectorProps) { unitId={unitId} subUnitId={subUnitId} visible={popupVisible} + mobile={mobile} maxRangeCount={maxRangeCount} onConfirm={(ranges) => { const resultStr = stringifyRanges(ranges); diff --git a/packages/sheets-hyper-link-ui/src/services/__tests__/popup.service.spec.ts b/packages/sheets-hyper-link-ui/src/services/__tests__/popup.service.spec.ts index 6d61c46d9c..32a2931acc 100644 --- a/packages/sheets-hyper-link-ui/src/services/__tests__/popup.service.spec.ts +++ b/packages/sheets-hyper-link-ui/src/services/__tests__/popup.service.spec.ts @@ -14,10 +14,12 @@ * limitations under the License. */ -import { CustomRangeType, Injector, IUniverInstanceService } from '@univerjs/core'; +import { ContextService, CustomRangeType, IContextService, Injector, IUniverInstanceService } from '@univerjs/core'; import { DocSelectionManagerService } from '@univerjs/docs'; import { IRenderManagerService } from '@univerjs/engine-render'; import { IEditorBridgeService, SheetCanvasPopManagerService } from '@univerjs/sheets-ui'; +import { IDialogService } from '@univerjs/ui'; +import { of } from 'rxjs'; import { describe, expect, it } from 'vitest'; import { HyperLinkEditSourceType } from '../../types/enums/edit-source'; import { SheetsHyperLinkPopupService } from '../popup.service'; @@ -101,14 +103,30 @@ class TestRenderManagerService { } } +class TestDialogService { + open(): TestDisposable { + return new TestDisposable(); + } + + close(): void {} + + closeAll(): void {} + + getDialogs$() { + return of([]); + } +} + function createService() { const injector = new Injector(); + injector.add([IContextService, { useClass: ContextService }]); injector.add([SheetCanvasPopManagerService, { useClass: TestSheetCanvasPopManagerService as never }]); injector.add([IUniverInstanceService, { useClass: TestUniverInstanceService as never }]); injector.add([IEditorBridgeService, { useClass: TestEditorBridgeService as never }]); injector.add([DocSelectionManagerService, { useClass: TestDocSelectionManagerService as never }]); injector.add([IRenderManagerService, { useClass: TestRenderManagerService as never }]); + injector.add([IDialogService, { useClass: TestDialogService }]); injector.add([SheetsHyperLinkPopupService]); return { diff --git a/packages/sheets-hyper-link-ui/src/services/popup.service.ts b/packages/sheets-hyper-link-ui/src/services/popup.service.ts index 1c9e801871..e86143558f 100644 --- a/packages/sheets-hyper-link-ui/src/services/popup.service.ts +++ b/packages/sheets-hyper-link-ui/src/services/popup.service.ts @@ -18,11 +18,22 @@ import type { ICustomRange, IDisposable, INeedCheckDisposable, ITextRange, Nulla import type { IBoundRectNoAngle } from '@univerjs/engine-render'; import type { ISheetLocationBase } from '@univerjs/sheets'; import type { ICanvasPopup } from '@univerjs/sheets-ui'; -import { BuildTextUtils, CustomRangeType, Disposable, DOCS_NORMAL_EDITOR_UNIT_ID_KEY, Inject, Injector, IUniverInstanceService, UniverInstanceType } from '@univerjs/core'; +import { + BuildTextUtils, + CustomRangeType, + Disposable, + DOCS_NORMAL_EDITOR_UNIT_ID_KEY, + IContextService, + Inject, + Injector, + IUniverInstanceService, + UniverInstanceType, +} from '@univerjs/core'; import { DocSelectionManagerService } from '@univerjs/docs'; import { calcDocRangePositions } from '@univerjs/docs-ui'; import { IRenderManagerService } from '@univerjs/engine-render'; import { getCustomRangePosition, getEditingCustomRangePosition, IEditorBridgeService, SheetCanvasPopManagerService } from '@univerjs/sheets-ui'; +import { IDialogService, MOBILE_UI_MODE } from '@univerjs/ui'; import { BehaviorSubject, Subject } from 'rxjs'; import { HyperLinkEditSourceType } from '../types/enums/edit-source'; import { CellLinkEdit } from '../views/CellLinkEdit'; @@ -50,6 +61,9 @@ interface IHyperLinkEditing { type: HyperLinkEditSourceType; } +const MOBILE_HYPER_LINK_EDITOR_DIALOG_ID = 'sheet-mobile-hyper-link-editor'; +const MOBILE_HYPER_LINK_VIEWER_DIALOG_ID = 'sheet-mobile-hyper-link-viewer'; + const isEqualLink = (a: IHyperLinkPopupOptions, b: Omit) => { return ( a.unitId === b.unitId @@ -94,7 +108,10 @@ export class SheetsHyperLinkPopupService extends Disposable { @Inject(Injector) private readonly _injector: Injector, @IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService, @IEditorBridgeService private readonly _editorBridgeService: IEditorBridgeService, - @Inject(DocSelectionManagerService) private readonly _textSelectionManagerService: DocSelectionManagerService + @Inject(DocSelectionManagerService) private readonly _textSelectionManagerService: DocSelectionManagerService, + @IRenderManagerService private readonly _renderManagerService: IRenderManagerService, + @IContextService private readonly _contextService: IContextService, + @IDialogService private readonly _dialogService: IDialogService ) { super(); @@ -127,6 +144,36 @@ export class SheetsHyperLinkPopupService extends Disposable { } const { unitId, subUnitId, row, col, customRangeRect, customRange } = location; + const mobileDialogService = this._getMobileDialogService(); + if (mobileDialogService) { + if (!location.showAll && !customRange) { + return; + } + const disposable: INeedCheckDisposable = { + canDispose: () => true, + dispose: () => mobileDialogService.close(MOBILE_HYPER_LINK_VIEWER_DIALOG_ID), + }; + this._currentPopup = { + unitId, + subUnitId, + disposable, + row, + col, + editPermission: !!location.editPermission, + copyPermission: !!location.copyPermission, + customRange, + type: location.type, + showAll: location.showAll, + }; + this._currentPopup$.next(this._currentPopup); + mobileDialogService.open({ + id: MOBILE_HYPER_LINK_VIEWER_DIALOG_ID, + title: { title: 'sheets-hyper-link-ui.form.addTitle' }, + children: { label: CellLinkPopup.componentKey }, + onClose: () => this.hideCurrentPopup(undefined, true), + }); + return; + } let disposable: Nullable; const popup: ICanvasPopup = { componentKey: CellLinkPopup.componentKey, @@ -252,6 +299,23 @@ export class SheetsHyperLinkPopupService extends Disposable { return popup; } + private _openMobileEditor(editing: IHyperLinkEditing & { customRange?: ICustomRange; label?: string }): void { + const dialogService = this._getMobileDialogService(); + if (!dialogService) return; + this._currentEditing$.next(editing); + dialogService.open({ + id: MOBILE_HYPER_LINK_EDITOR_DIALOG_ID, + title: { title: 'sheets-hyper-link-ui.form.addTitle' }, + children: { label: CellLinkEdit.componentKey }, + maskClosable: false, + onClose: () => this.endEditing(editing.type), + }); + } + + private _getMobileDialogService(): IDialogService | null { + return this._contextService.getContextValue(MOBILE_UI_MODE) ? this._dialogService : null; + } + startAddEditing(link: IHyperLinkEditing) { const { unitId, subUnitId, type } = link; if (type === HyperLinkEditSourceType.EDITING) { @@ -262,7 +326,11 @@ export class SheetsHyperLinkPopupService extends Disposable { } this._textSelectionManagerService.replaceDocRanges([{ ...range }], { unitId: DOCS_NORMAL_EDITOR_UNIT_ID_KEY, subUnitId: DOCS_NORMAL_EDITOR_UNIT_ID_KEY }); - const currentRender = this._injector.get(IRenderManagerService).getRenderUnitById(DOCS_NORMAL_EDITOR_UNIT_ID_KEY); + if (this._getMobileDialogService()) { + this._openMobileEditor({ ...link, label: range.label }); + return; + } + const currentRender = this._renderManagerService.getRenderUnitById(DOCS_NORMAL_EDITOR_UNIT_ID_KEY); if (!currentRender) { return; } @@ -282,6 +350,14 @@ export class SheetsHyperLinkPopupService extends Disposable { label: range?.label ?? '', }); } else { + const workbook = this._univerInstanceService.getUnit(unitId, UniverInstanceType.UNIVER_SHEET); + const worksheet = workbook?.getSheetBySheetId(subUnitId); + const cell = worksheet?.getCellRaw(link.row, link.col); + const label = cell?.p ? BuildTextUtils.transform.getPlainText(cell.p.body?.dataStream ?? '') : (cell?.v ?? '').toString(); + if (this._getMobileDialogService()) { + this._openMobileEditor({ ...link, label }); + return; + } this._currentEditingPopup = this._sheetCanvasPopManagerService.attachPopupToCell( link.row, link.col, @@ -289,12 +365,9 @@ export class SheetsHyperLinkPopupService extends Disposable { unitId, subUnitId ); - const workbook = this._univerInstanceService.getUnit(unitId, UniverInstanceType.UNIVER_SHEET); - const worksheet = workbook?.getSheetBySheetId(subUnitId); - const cell = worksheet?.getCellRaw(link.row, link.col); this._currentEditing$.next({ ...link, - label: cell?.p ? BuildTextUtils.transform.getPlainText(cell.p.body?.dataStream ?? '') : (cell?.v ?? '').toString(), + label, }); } } @@ -304,6 +377,7 @@ export class SheetsHyperLinkPopupService extends Disposable { this.hideCurrentPopup(undefined, true); const { unitId, subUnitId } = link; + const mobile = Boolean(this._getMobileDialogService()); let customRange; let label; if (link.type === HyperLinkEditSourceType.EDITING) { @@ -319,12 +393,14 @@ export class SheetsHyperLinkPopupService extends Disposable { endOffset: customRange.endIndex + 1, }, ]); - this._currentEditingPopup = this._sheetCanvasPopManagerService.attachPopupToAbsolutePosition( - customRangeInfo.rects.pop()!, - this._editPopup, - unitId, - subUnitId - ); + if (!mobile) { + this._currentEditingPopup = this._sheetCanvasPopManagerService.attachPopupToAbsolutePosition( + customRangeInfo.rects.pop()!, + this._editPopup, + unitId, + subUnitId + ); + } } else { const workbook = this._univerInstanceService.getUnit(unitId, UniverInstanceType.UNIVER_SHEET); const worksheet = workbook?.getSheetBySheetId(subUnitId); @@ -338,32 +414,39 @@ export class SheetsHyperLinkPopupService extends Disposable { } customRange = customRangeInfo.customRange; label = customRangeInfo.label; - if (tr) { - this._currentEditingPopup = this._sheetCanvasPopManagerService.attachPopupToCell( - link.row, - link.col, - this._editPopup, - unitId, - subUnitId - ); - } else { - this._currentEditingPopup = this._sheetCanvasPopManagerService.attachPopupByPosition( - customRangeInfo.rects.pop()!, - this._editPopup, - { + if (!mobile) { + if (tr) { + this._currentEditingPopup = this._sheetCanvasPopManagerService.attachPopupToCell( + link.row, + link.col, + this._editPopup, unitId, - subUnitId, - row: link.row, - col: link.col, - } - ); + subUnitId + ); + } else { + this._currentEditingPopup = this._sheetCanvasPopManagerService.attachPopupByPosition( + customRangeInfo.rects.pop()!, + this._editPopup, + { + unitId, + subUnitId, + row: link.row, + col: link.col, + } + ); + } } } - this._currentEditing$.next({ + const editing = { ...link, customRange, label, - }); + }; + if (mobile) { + this._openMobileEditor(editing); + return; + } + this._currentEditing$.next(editing); } endEditing(type?: HyperLinkEditSourceType) { @@ -373,6 +456,7 @@ export class SheetsHyperLinkPopupService extends Disposable { const current = this._currentEditing$.getValue(); if (current && (!type || type === current.type)) { this._currentEditingPopup?.dispose(); + this._getMobileDialogService()?.close(MOBILE_HYPER_LINK_EDITOR_DIALOG_ID); this._currentEditing$.next(null); } } diff --git a/packages/sheets-hyper-link-ui/src/views/CellLinkEdit.tsx b/packages/sheets-hyper-link-ui/src/views/CellLinkEdit.tsx index e67944ce2f..1dacb781bc 100644 --- a/packages/sheets-hyper-link-ui/src/views/CellLinkEdit.tsx +++ b/packages/sheets-hyper-link-ui/src/views/CellLinkEdit.tsx @@ -31,7 +31,7 @@ import { Tools, UniverInstanceType, } from '@univerjs/core'; -import { borderClassName, Button, clsx, FormLayout, Input, Select } from '@univerjs/design'; +import { ActionRow, borderClassName, Button, clsx, ConfigContext, FormLayout, Input, Select } from '@univerjs/design'; import { DocSelectionManagerService } from '@univerjs/docs'; import { DocSelectionRenderService } from '@univerjs/docs-ui'; import { @@ -53,7 +53,7 @@ import { } from '@univerjs/sheets-hyper-link'; import { IEditorBridgeService, IMarkSelectionService, ScrollToRangeOperation } from '@univerjs/sheets-ui'; import { KeyCode, useDependency, useEvent, useObservable } from '@univerjs/ui'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; import { CloseHyperLinkPopupOperation } from '../commands/operations/popup.operations'; import { isLegalLink, serializeUrl } from '../common/util'; import { SheetsHyperLinkPopupService } from '../services/popup.service'; @@ -71,6 +71,7 @@ export const CellLinkEdit = () => { const [payload, setPayload] = useState(''); const localeService = useDependency(LocaleService); + const { mobile } = useContext(ConfigContext); const definedNameService = useDependency(IDefinedNamesService); const editorBridgeService = useDependency(IEditorBridgeService); const univerInstanceService = useDependency(IUniverInstanceService); @@ -426,10 +427,15 @@ export const CellLinkEdit = () => { return (
    {showLabel ? ( @@ -566,7 +572,12 @@ export const CellLinkEdit = () => { setPayload={setPayload} /> )} -
    + -
    +
    ); }; diff --git a/packages/sheets-hyper-link-ui/src/views/CellLinkPopup.tsx b/packages/sheets-hyper-link-ui/src/views/CellLinkPopup.tsx index b14e715da6..5b6f899ee3 100644 --- a/packages/sheets-hyper-link-ui/src/views/CellLinkPopup.tsx +++ b/packages/sheets-hyper-link-ui/src/views/CellLinkPopup.tsx @@ -17,7 +17,7 @@ import type { ICustomRange, Nullable, Workbook } from '@univerjs/core'; import type { LocaleKey } from '../locale/types'; import { ICommandService, IUniverInstanceService, LocaleService, UniverInstanceType } from '@univerjs/core'; -import { borderClassName, Button, clsx, MessageType, Tooltip } from '@univerjs/design'; +import { borderClassName, Button, clsx, ConfigContext, MessageType, Tooltip } from '@univerjs/design'; import { AllBorderIcon, CopyIcon, LinkIcon, SheetsMultiIcon, UnlinkIcon, WriteIcon } from '@univerjs/icons'; import { CancelHyperLinkCommand, @@ -27,10 +27,12 @@ import { } from '@univerjs/sheets-hyper-link'; import { IEditorBridgeService } from '@univerjs/sheets-ui'; import { IMessageService, useDependency, useObservable } from '@univerjs/ui'; +import { useContext } from 'react'; import { OpenHyperLinkEditPanelOperation } from '../commands/operations/popup.operations'; import { SheetsHyperLinkPopupService } from '../services/popup.service'; import { SheetsHyperLinkResolverService } from '../services/resolver.service'; import { HyperLinkEditSourceType } from '../types/enums/edit-source'; +import { MobileCellLinkPopup } from './MobileCellLinkPopup'; const iconsMap = { [SheetHyperLinkType.URL]: , @@ -59,6 +61,7 @@ export const CellLinkPopupPure = (props: ICellLinkPopupPureProps) => { const resolverService = useDependency(SheetsHyperLinkResolverService); const editorBridgeService = useDependency(IEditorBridgeService); const parserHyperLinkService = useDependency(SheetsHyperLinkParserService); + const { mobile } = useContext(ConfigContext); const { customRange, row, col, unitId, subUnitId, editPermission, copyPermission, type } = props; if (!customRange?.properties?.url) { @@ -67,6 +70,65 @@ export const CellLinkPopupPure = (props: ICellLinkPopupPureProps) => { const linkObj = parserHyperLinkService.parseHyperLink(customRange.properties.url ?? ''); const isError = linkObj.type === SheetHyperLinkType.INVALID; + if (mobile) { + const close = () => popupService.hideCurrentPopup(undefined, true); + return ( + ('sheets-hyper-link-ui.popup.copy')} + editText={localeService.t('sheets-hyper-link-ui.popup.edit')} + removeText={localeService.t('sheets-hyper-link-ui.popup.cancel')} + onNavigate={() => { + resolverService.navigate(linkObj); + close(); + }} + onCopy={() => { + if (linkObj.type !== SheetHyperLinkType.URL) { + const url = new URL(window.location.href); + url.hash = linkObj.url.slice(1); + navigator.clipboard.writeText(url.href); + } else { + navigator.clipboard.writeText(linkObj.url); + } + messageService.show({ + content: localeService.t('sheets-hyper-link-ui.message.coped'), + type: MessageType.Info, + }); + close(); + }} + onEdit={() => { + close(); + commandService.executeCommand(OpenHyperLinkEditPanelOperation.id, { + unitId, + subUnitId, + row, + col, + customRangeId: customRange.rangeId, + type, + }); + }} + onRemove={() => { + const commandId = type === HyperLinkEditSourceType.EDITING + ? CancelRichHyperLinkCommand.id + : CancelHyperLinkCommand.id; + if (commandService.syncExecuteCommand(commandId, { + unitId, + subUnitId, + id: customRange.rangeId, + row, + column: col, + documentId: editorBridgeService.getCurrentEditorId(), + })) { + close(); + } + }} + /> + ); + } + return (
    void; + onCopy: () => void; + onEdit: () => void; + onRemove: () => void; +} + +export function MobileCellLinkPopup(props: IMobileCellLinkPopupProps) { + return ( +
    + {props.name}} + aria-label={props.name} + variant="subtle" + disabled={props.invalid} + onClick={props.onNavigate} + /> + {props.copyPermission && ( + + )} + {props.editPermission && ( + <> + + + + )} +
    + ); +} diff --git a/packages/sheets-hyper-link-ui/src/views/__tests__/CellLinkPopup.spec.tsx b/packages/sheets-hyper-link-ui/src/views/__tests__/CellLinkPopup.spec.tsx index 4d69ba72de..cba2cf34b7 100644 --- a/packages/sheets-hyper-link-ui/src/views/__tests__/CellLinkPopup.spec.tsx +++ b/packages/sheets-hyper-link-ui/src/views/__tests__/CellLinkPopup.spec.tsx @@ -52,9 +52,10 @@ import { ScrollToRangeOperation, SheetCanvasPopManagerService, } from '@univerjs/sheets-ui'; -import { IMessageService, RediContext } from '@univerjs/ui'; +import { IDialogService, IMessageService, RediContext } from '@univerjs/ui'; import { act } from 'react'; import { createRoot } from 'react-dom/client'; +import { of } from 'rxjs'; import { afterEach, describe, expect, it } from 'vitest'; import { CloseHyperLinkPopupOperation, OpenHyperLinkEditPanelOperation } from '../../commands/operations/popup.operations'; import { SheetsHyperLinkPopupService } from '../../services/popup.service'; @@ -181,6 +182,20 @@ class TestMessageService { } } +class TestDialogService { + open() { + return toDisposable(() => undefined); + } + + close(): void {} + + closeAll(): void {} + + getDialogs$() { + return of([]); + } +} + function createCellDocument(text: string, range: ICustomRange) { return { id: `${range.rangeId}-doc`, @@ -246,6 +261,7 @@ function createPopupTestBed() { injector.add([IDefinedNamesService, { useClass: TestDefinedNamesService as never }]); injector.add([IRenderManagerService, { useClass: TestRenderManagerService as never }]); injector.add([IMessageService, { useClass: TestMessageService as never }]); + injector.add([IDialogService, { useClass: TestDialogService }]); injector.add([IEditorBridgeService, { useClass: TestEditorBridgeService as never }]); injector.add([IMarkSelectionService, { useClass: TestMarkSelectionService as never }]); injector.add([SheetCanvasPopManagerService, { useClass: TestCanvasPopManagerService as never }]); diff --git a/packages/sheets-numfmt-ui/src/commands/commands/set-mobile-numfmt.command.ts b/packages/sheets-numfmt-ui/src/commands/commands/set-mobile-numfmt.command.ts new file mode 100644 index 0000000000..853ae64cca --- /dev/null +++ b/packages/sheets-numfmt-ui/src/commands/commands/set-mobile-numfmt.command.ts @@ -0,0 +1,43 @@ +/** + * 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 { IAccessor, ICommand } from '@univerjs/core'; +import type { ISetNumfmtCommandParams } from '@univerjs/sheets-numfmt'; +import { CommandType, ICommandService, Range } from '@univerjs/core'; +import { SheetsSelectionsService } from '@univerjs/sheets'; +import { getPatternType, SetNumfmtCommand } from '@univerjs/sheets-numfmt'; + +export interface ISetMobileNumfmtCommandParams { + value?: string | null; +} + +export const SetMobileNumfmtCommand: ICommand = { + id: 'sheet.command.mobile.numfmt.set', + type: CommandType.COMMAND, + handler: async (accessor: IAccessor, params) => { + const selections = accessor.get(SheetsSelectionsService).getCurrentSelections(); + if (!selections?.length) return false; + + const pattern = params?.value || undefined; + const type = pattern ? getPatternType(pattern) : undefined; + const values: ISetNumfmtCommandParams['values'] = []; + selections.forEach((selection) => { + Range.foreach(selection.range, (row, col) => values.push({ row, col, pattern, type })); + }); + + return accessor.get(ICommandService).executeCommand(SetNumfmtCommand.id, { values }); + }, +}; diff --git a/packages/sheets-numfmt-ui/src/controllers/ui.controller.ts b/packages/sheets-numfmt-ui/src/controllers/ui.controller.ts index 238d8574c3..3a9f2bb2de 100644 --- a/packages/sheets-numfmt-ui/src/controllers/ui.controller.ts +++ b/packages/sheets-numfmt-ui/src/controllers/ui.controller.ts @@ -52,6 +52,7 @@ import { SheetSkeletonManagerService } from '@univerjs/sheets-ui'; import { ISidebarService } from '@univerjs/ui'; import { combineLatest, merge, Observable } from 'rxjs'; import { debounceTime, map, switchMap, tap } from 'rxjs/operators'; +import { SetMobileNumfmtCommand } from '../commands/commands/set-mobile-numfmt.command'; import { CloseNumfmtPanelOperator } from '../commands/operations/close.numfmt.panel.operation'; import { OpenNumfmtPanelOperator } from '../commands/operations/open.numfmt.panel.operation'; @@ -188,6 +189,7 @@ export class SheetNumfmtUIController extends Disposable { [ OpenNumfmtPanelOperator, CloseNumfmtPanelOperator, + SetMobileNumfmtCommand, ].forEach((config) => { this.disposeWithMe(this._commandService.registerCommand(config)); }); diff --git a/packages/sheets-numfmt-ui/src/menu/menu.ts b/packages/sheets-numfmt-ui/src/menu/menu.ts index 935373412b..e6aba7a404 100644 --- a/packages/sheets-numfmt-ui/src/menu/menu.ts +++ b/packages/sheets-numfmt-ui/src/menu/menu.ts @@ -39,8 +39,11 @@ import { } from '@univerjs/sheets'; import { AddDecimalCommand, + CURRENCYFORMAT, + DATEFMTLISG, getCurrencySymbolByLocale, getCurrencySymbolIconByLocale, + NUMBERFORMAT, SetCurrencyCommand, SetPercentCommand, SubtractDecimalCommand, @@ -48,6 +51,7 @@ import { import { deriveStateFromActiveSheet$, getCurrentRangeDisable$ } from '@univerjs/sheets-ui'; import { getMenuHiddenObservable, MenuItemType } from '@univerjs/ui'; import { filter, map, merge, Observable } from 'rxjs'; +import { SetMobileNumfmtCommand } from '../commands/commands/set-mobile-numfmt.command'; import { OpenNumfmtPanelOperator } from '../commands/operations/open.numfmt.panel.operation'; import { MORE_NUMFMT_TYPE_KEY, OPTIONS_KEY } from '../views/components/MoreNumfmtType'; @@ -119,6 +123,63 @@ export const MENU_OPTIONS = (currencySymbol: string): Array<{ ]; }; +export interface IMobileNumberFormatMenuConfig { + kind: 'number-format'; + title: LocaleKey; + commandId: string; + detailTitle: LocaleKey; + customTitle: LocaleKey; + quickOptions: Array<{ + label: LocaleKey; + commandId: string; + value?: string; + }>; + decimalOptions: Array<{ + label: LocaleKey; + commandId: string; + }>; + detailOptions: Array<{ + label?: LocaleKey; + value?: string | null; + divider?: boolean; + custom?: boolean; + }>; + customPatterns: string[]; +} + +function createMobileNumberFormatMenuConfig(currencySymbol: string): IMobileNumberFormatMenuConfig { + const detailOptions: IMobileNumberFormatMenuConfig['detailOptions'] = MENU_OPTIONS(currencySymbol) + .slice(0, -1) + .map((item) => item === '|' + ? { divider: true } + : { label: item.label, value: item.pattern }); + detailOptions.push({ label: 'sheets-numfmt-ui.customFormat', custom: true }); + + return { + kind: 'number-format', + title: 'sheets-numfmt-ui.title', + commandId: SetMobileNumfmtCommand.id, + detailTitle: 'sheets-numfmt-ui.moreFmt', + customTitle: 'sheets-numfmt-ui.customFormat', + quickOptions: [ + { label: 'sheets-numfmt-ui.percent', commandId: SetPercentCommand.id }, + { label: 'sheets-numfmt-ui.currency', commandId: SetCurrencyCommand.id }, + { label: 'sheets-numfmt-ui.date', commandId: SetMobileNumfmtCommand.id, value: 'yyyy-mm-dd;@' }, + { label: 'sheets-numfmt-ui.text', commandId: SetMobileNumfmtCommand.id, value: DEFAULT_TEXT_FORMAT_EXCEL }, + ], + decimalOptions: [ + { label: 'sheets-numfmt-ui.subtractDecimal', commandId: SubtractDecimalCommand.id }, + { label: 'sheets-numfmt-ui.addDecimal', commandId: AddDecimalCommand.id }, + ], + detailOptions, + customPatterns: [...new Set([ + ...CURRENCYFORMAT.map((item) => item.suffix(currencySymbol)), + ...DATEFMTLISG.map((item) => item.suffix), + ...NUMBERFORMAT.map((item) => item.suffix), + ])], + }; +} + export function CurrencySymbolIconMenuItem(accessor: IAccessor): IMenuButtonItem { const regionService = accessor.get(RegionService); @@ -186,7 +247,7 @@ export function PercentMenuItem(accessor: IAccessor): IMenuButtonItem }; }; -export function FactoryOtherMenuItem(accessor: IAccessor): IMenuSelectorItem { +export function FactoryOtherMenuItem(accessor: IAccessor): IMenuSelectorItem & { mobileStyle: IMobileNumberFormatMenuConfig } { const univerInstanceService = accessor.get(IUniverInstanceService); const commandService = accessor.get(ICommandService); const localeService = accessor.get(LocaleService); @@ -259,5 +320,6 @@ export function FactoryOtherMenuItem(accessor: IAccessor): IMenuSelectorItem key } as never, + new ContextService(), {} as never, {} as never, { attachPopupByPosition } as never diff --git a/packages/sheets-table-ui/src/controllers/sheet-table-component.controller.ts b/packages/sheets-table-ui/src/controllers/sheet-table-component.controller.ts index 1dfc0cfbc7..bd4053e7e4 100644 --- a/packages/sheets-table-ui/src/controllers/sheet-table-component.controller.ts +++ b/packages/sheets-table-ui/src/controllers/sheet-table-component.controller.ts @@ -17,7 +17,7 @@ import type { IDisposable, Nullable } from '@univerjs/core'; import { Disposable, IContextService, Inject } from '@univerjs/core'; import { SheetCanvasPopManagerService } from '@univerjs/sheets-ui'; -import { IDialogService } from '@univerjs/ui'; +import { IDialogService, MOBILE_UI_MODE } from '@univerjs/ui'; import { distinctUntilChanged, startWith } from 'rxjs'; import { SHEETS_TABLE_FILTER_PANEL_OPENED_KEY, UNIVER_SHEET_TABLE_FILTER_PANEL_ID } from '../const'; @@ -97,6 +97,17 @@ export class SheetsTableComponentController extends Disposable { } const { row: startRow, column: col } = currentFilterModel; + if (this._contextService.getContextValue(MOBILE_UI_MODE)) { + this._dialogService.open({ + id: UNIVER_SHEET_TABLE_FILTER_PANEL_ID, + title: { title: 'sheets-table-ui.filter.by-values' }, + children: { label: SHEETS_TABLE_FILTER_PANEL_OPENED_KEY }, + onClose: () => { + this._contextService.setContextValue(SHEETS_TABLE_FILTER_PANEL_OPENED_KEY, false); + }, + }); + return; + } this._popupDisposable = this._sheetCanvasPopupService.attachPopupToCell(startRow, col, { componentKey: SHEETS_TABLE_FILTER_PANEL_OPENED_KEY, direction: 'horizontal', @@ -110,6 +121,7 @@ export class SheetsTableComponentController extends Disposable { } private _closeFilterPopup(): void { + this._dialogService.close(UNIVER_SHEET_TABLE_FILTER_PANEL_ID); this._popupDisposable?.dispose(); this._popupDisposable = null; this.clearCurrentTableFilterInfo(); diff --git a/packages/sheets-table-ui/src/controllers/sheet-table-controls-render.controller.ts b/packages/sheets-table-ui/src/controllers/sheet-table-controls-render.controller.ts index 6598453cdf..2d7f2982af 100644 --- a/packages/sheets-table-ui/src/controllers/sheet-table-controls-render.controller.ts +++ b/packages/sheets-table-ui/src/controllers/sheet-table-controls-render.controller.ts @@ -30,6 +30,7 @@ import { Disposable, fromCallback, ICommandService, + IContextService, Inject, Injector, IPermissionService, @@ -64,7 +65,7 @@ import { SheetCanvasPopManagerService, SheetSkeletonManagerService, } from '@univerjs/sheets-ui'; -import { IDialogService, ISidebarService } from '@univerjs/ui'; +import { IDialogService, ISidebarService, MOBILE_UI_MODE } from '@univerjs/ui'; import { filter, merge } from 'rxjs'; import { openRangeSelector } from '../commands/operations/open-table-selector.operation'; import { @@ -80,6 +81,7 @@ import { SheetTableThemeUIController } from './sheet-table-theme-ui.controller'; const TABLE_CONTROLS_LAYER_INDEX = 5002; const TABLE_CONTROL_GAP_ROW = 0; +const SHEET_TABLE_MOBILE_MENU_DIALOG_ID = 'sheet-table-mobile-menu'; const TABLE_RENDER_REFRESH_COMMANDS = new Set([ SetScrollOperation.id, @@ -121,6 +123,7 @@ export class SheetTableControlsRenderController extends Disposable implements IR @ISheetSelectionRenderService private readonly _selectionRenderService: ISheetSelectionRenderService, @Inject(SheetTableThemeUIController) private readonly _sheetTableThemeUIController: SheetTableThemeUIController, @Inject(LocaleService) private readonly _localeService: LocaleService, + @IContextService private readonly _contextService: IContextService, @IDialogService private readonly _dialogService: IDialogService, @ISidebarService private readonly _sidebarService: ISidebarService, @Inject(SheetCanvasPopManagerService) private readonly _sheetCanvasPopupService: SheetCanvasPopManagerService @@ -338,6 +341,34 @@ export class SheetTableControlsRenderController extends Disposable implements IR this._closeFloatingControls(); this._shape.setOpenedMenuTableId(tableId); const range = table.getRange(); + const extraProps = { + anchorWidth: anchor.width, + tableName: table.getDisplayName(), + labels: { + rename: this._localeService.t('sheets-table-ui.rename'), + 'update-range': this._localeService.t('sheets-table-ui.updateRange'), + 'set-theme': this._localeService.t('sheets-table-ui.setTheme'), + delete: this._localeService.t('sheets-table-ui.removeTable'), + }, + onSelect: (action: SheetTableMenuAction) => this._handleTableMenuAction(action, unitId, subUnitId, tableId), + onClose: () => this._closeFloatingControls(), + }; + + if (this._contextService.getContextValue(MOBILE_UI_MODE)) { + this._dialogService.open({ + id: SHEET_TABLE_MOBILE_MENU_DIALOG_ID, + title: { title: table.getDisplayName() }, + children: { + label: { + name: SHEET_TABLE_MENU, + props: { popup: { extraProps } }, + }, + }, + onClose: () => this._closeFloatingControls(), + }); + this._menuPopup = toDisposable(() => this._dialogService.close(SHEET_TABLE_MOBILE_MENU_DIALOG_ID)); + return; + } this._menuPopup = this._sheetCanvasPopupService.attachPopupByPosition( { @@ -349,18 +380,7 @@ export class SheetTableControlsRenderController extends Disposable implements IR { componentKey: SHEET_TABLE_MENU, direction: 'bottom-left', - extraProps: { - anchorWidth: anchor.width, - tableName: table.getDisplayName(), - labels: { - rename: this._localeService.t('sheets-table-ui.rename'), - 'update-range': this._localeService.t('sheets-table-ui.updateRange'), - 'set-theme': this._localeService.t('sheets-table-ui.setTheme'), - delete: this._localeService.t('sheets-table-ui.removeTable'), - }, - onSelect: (action: SheetTableMenuAction) => this._handleTableMenuAction(action, unitId, subUnitId, tableId), - onClose: () => this._closeFloatingControls(), - }, + extraProps, }, { unitId, diff --git a/packages/sheets-table-ui/src/views/components/MobileSheetTableMenu.tsx b/packages/sheets-table-ui/src/views/components/MobileSheetTableMenu.tsx new file mode 100644 index 0000000000..78c3fa301f --- /dev/null +++ b/packages/sheets-table-ui/src/views/components/MobileSheetTableMenu.tsx @@ -0,0 +1,36 @@ +/** + * 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 { ISheetTableMenuExtraProps, SheetTableMenuAction } from './SheetTableMenu'; +import { MobileActionRow } from '@univerjs/design'; + +const ACTIONS: SheetTableMenuAction[] = ['rename', 'update-range', 'set-theme', 'delete']; + +export function MobileSheetTableMenu({ menu }: { menu: ISheetTableMenuExtraProps }) { + return ( +
    + {ACTIONS.map((action) => ( + menu.onSelect(action)} + /> + ))} +
    + ); +} diff --git a/packages/sheets-table-ui/src/views/components/SheetTableFilterPanel.tsx b/packages/sheets-table-ui/src/views/components/SheetTableFilterPanel.tsx index 39d92bd177..625ab5dd7c 100644 --- a/packages/sheets-table-ui/src/views/components/SheetTableFilterPanel.tsx +++ b/packages/sheets-table-ui/src/views/components/SheetTableFilterPanel.tsx @@ -19,7 +19,7 @@ import type { ITableConditionFilterItem, ITableManualFilterItem } from '@univerj import type { LocaleKey } from '../../locale/types'; import type { IConditionInfo } from './type'; import { ICommandService, IPermissionService, LocaleService } from '@univerjs/core'; -import { Button, ButtonGroup, Segmented } from '@univerjs/design'; +import { ActionRow, Button, ButtonGroup, Segmented } from '@univerjs/design'; import { AscendingIcon, DeleteColumnDoubleIcon, @@ -312,7 +312,7 @@ export function SheetTableFilterPanel() { )}
    -
    {localeService.t('sheets-table-ui.filter.clear-filter')} -
    + -
    -
    + + ); } diff --git a/packages/sheets-table-ui/src/views/components/SheetTableMenu.tsx b/packages/sheets-table-ui/src/views/components/SheetTableMenu.tsx index 3366087169..5f5466eb30 100644 --- a/packages/sheets-table-ui/src/views/components/SheetTableMenu.tsx +++ b/packages/sheets-table-ui/src/views/components/SheetTableMenu.tsx @@ -15,7 +15,9 @@ */ import type { ICanvasPopup } from '@univerjs/sheets-ui'; -import { DropdownMenu } from '@univerjs/design'; +import { ConfigContext, DropdownMenu } from '@univerjs/design'; +import { useContext } from 'react'; +import { MobileSheetTableMenu } from './MobileSheetTableMenu'; export type SheetTableMenuAction = 'rename' | 'update-range' | 'set-theme' | 'delete'; @@ -33,11 +35,16 @@ interface ISheetTableMenuProps { export function SheetTableMenu({ popup }: ISheetTableMenuProps) { const menu = popup.extraProps as unknown as ISheetTableMenuExtraProps | undefined; + const { mobile } = useContext(ConfigContext); if (!menu) { return null; } + if (mobile) { + return ; + } + return ( {error ?
    {error}
    : null} -
    + -
    + ); } diff --git a/packages/sheets-table-ui/src/views/components/SheetTableSelector.tsx b/packages/sheets-table-ui/src/views/components/SheetTableSelector.tsx index 68ad010790..6c7493689b 100644 --- a/packages/sheets-table-ui/src/views/components/SheetTableSelector.tsx +++ b/packages/sheets-table-ui/src/views/components/SheetTableSelector.tsx @@ -17,7 +17,7 @@ import type { ITableSelectionInfo } from '../../commands/operations/open-table-selector.operation'; import type { LocaleKey } from '../../locale/types'; import { IUniverInstanceService, LocaleService, Rectangle } from '@univerjs/core'; -import { Button } from '@univerjs/design'; +import { ActionRow, Button } from '@univerjs/design'; import { deserializeRangeWithSheet, serializeRange } from '@univerjs/engine-formula'; import { getSheetCommandTarget } from '@univerjs/sheets'; import { RangeSelector } from '@univerjs/sheets-formula-ui'; @@ -116,7 +116,7 @@ export const SheetTableSelector = (props: ITableSelectionInfo & { )} -
    + -
    + ); }; diff --git a/packages/sheets-table-ui/src/views/components/__tests__/SheetTableComponents.spec.tsx b/packages/sheets-table-ui/src/views/components/__tests__/SheetTableComponents.spec.tsx index a5feb15b6a..76545c4de0 100644 --- a/packages/sheets-table-ui/src/views/components/__tests__/SheetTableComponents.spec.tsx +++ b/packages/sheets-table-ui/src/views/components/__tests__/SheetTableComponents.spec.tsx @@ -1411,7 +1411,7 @@ describe('sheet table view components', () => { clickElement(headerColorButton); const colorPicker = document.querySelector('[data-u-comp="color-picker"]'); - const moreColorControl = colorPicker?.querySelector('a'); + const moreColorControl = Array.from(colorPicker?.querySelectorAll('button') ?? []).at(-1); if (!moreColorControl) { throw new Error('More color control was not found.'); } diff --git a/packages/sheets-thread-comment-ui/src/index.ts b/packages/sheets-thread-comment-ui/src/index.ts index 6c82c95a9d..b63d291263 100644 --- a/packages/sheets-thread-comment-ui/src/index.ts +++ b/packages/sheets-thread-comment-ui/src/index.ts @@ -22,5 +22,6 @@ export { } from './commands/operations/comment.operation'; export type { IUniverSheetsThreadCommentUIConfig } from './config/config'; export { menuSchema as SheetsThreadCommentUIMenuSchema } from './menu/schema'; +export { UniverSheetsThreadCommentMobileUIPlugin } from './mobile-plugin'; export { UniverSheetsThreadCommentUIPlugin } from './plugin'; export { SheetsThreadCommentPopupService } from './services/sheets-thread-comment-popup.service'; diff --git a/packages/sheets-thread-comment-ui/src/mobile-plugin.ts b/packages/sheets-thread-comment-ui/src/mobile-plugin.ts new file mode 100644 index 0000000000..212bee2893 --- /dev/null +++ b/packages/sheets-thread-comment-ui/src/mobile-plugin.ts @@ -0,0 +1,59 @@ +/** + * 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 { IUniverSheetsThreadCommentUIConfig } from './config/config'; +import { DependentOn, ICommandService, IConfigService, Inject, Injector } from '@univerjs/core'; +import { IRenderManagerService, UniverRenderEnginePlugin } from '@univerjs/engine-render'; +import { UniverSheetsPlugin } from '@univerjs/sheets'; +import { UniverSheetsThreadCommentPlugin } from '@univerjs/sheets-thread-comment'; +import { UniverSheetsMobileUIPlugin } from '@univerjs/sheets-ui'; +import { UniverThreadCommentPlugin } from '@univerjs/thread-comment'; +import { UniverThreadCommentMobileUIPlugin } from '@univerjs/thread-comment-ui'; +import { UniverMobileUIPlugin } from '@univerjs/ui'; +import { defaultPluginConfig } from './config/config'; +import { SheetsThreadCommentCopyPasteController } from './controllers/sheets-thread-comment-copy-paste.controller'; +import { SheetsThreadCommentPermissionController } from './controllers/sheets-thread-comment-permission.controller'; +import { SheetsThreadCommentPopupController } from './controllers/sheets-thread-comment-popup.controller'; +import { UniverSheetsThreadCommentUIPlugin } from './plugin'; + +@DependentOn( + UniverRenderEnginePlugin, + UniverThreadCommentPlugin, + UniverSheetsPlugin, + UniverThreadCommentMobileUIPlugin, + UniverSheetsThreadCommentPlugin, + UniverMobileUIPlugin, + UniverSheetsMobileUIPlugin +) +export class UniverSheetsThreadCommentMobileUIPlugin extends UniverSheetsThreadCommentUIPlugin { + static override pluginName = UniverSheetsThreadCommentUIPlugin.pluginName; + + constructor( + config: Partial = defaultPluginConfig, + @Inject(Injector) injector: Injector, + @Inject(ICommandService) commandService: ICommandService, + @IRenderManagerService renderManagerService: IRenderManagerService, + @IConfigService configService: IConfigService + ) { + super(config, injector, commandService, renderManagerService, configService); + } + + override onRendered(): void { + this._injector.get(SheetsThreadCommentCopyPasteController); + this._injector.get(SheetsThreadCommentPopupController); + this._injector.get(SheetsThreadCommentPermissionController); + } +} diff --git a/packages/sheets-ui/src/commands/commands/set-selection.command.ts b/packages/sheets-ui/src/commands/commands/set-selection.command.ts index c9a13f73f0..f681b82952 100644 --- a/packages/sheets-ui/src/commands/commands/set-selection.command.ts +++ b/packages/sheets-ui/src/commands/commands/set-selection.command.ts @@ -158,6 +158,7 @@ export const MoveSelectionCommand: ICommand = { selections, type: SelectionMoveType.MOVE_END, extra, + fromCurrentSelection: params.fromCurrentSelection, reveal: true, } as ISetSelectionsOperationParams); return rs; @@ -180,7 +181,7 @@ export const MoveSelectionEnterAndTabCommand: ICommand { function createController(options: { isFocusFxBar?: boolean; + mobile?: boolean; themeTextColor?: string; formulaBarPosition?: { width: number; height: number } | null; } = {}) { @@ -83,8 +85,11 @@ describe('EditorDataSyncController', () => { isForceKeepVisible: vi.fn(() => false), }; const contextService = { - getContextValue: vi.fn((key: string) => - key === FOCUSING_FX_BAR_EDITOR ? options.isFocusFxBar ?? false : false), + getContextValue: vi.fn((key: string) => { + if (key === FOCUSING_FX_BAR_EDITOR) return options.isFocusFxBar ?? false; + if (key === MOBILE_SHEET_FX_EDITOR) return options.mobile ?? false; + return false; + }), subscribeContextValue$: vi.fn(() => EMPTY), }; const currentTheme$ = new BehaviorSubject({}); @@ -279,6 +284,30 @@ describe('EditorDataSyncController', () => { expect(formulaBarSnapshot.documentStyle.textStyle?.cl?.rgb).toBe('#f7f9fc'); }); + it('renders source rich-text styles in the mobile formula bar without changing the desktop behavior', () => { + const desktop = createController().controller; + const mobile = createController({ mobile: true }).controller; + const createSnapshot = (): IDocumentData => ({ + id: DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY, + body: { + dataStream: 'Rich text\r\n', + textRuns: [{ st: 0, ed: 4, ts: { bl: BooleanNumber.TRUE, cl: { rgb: '#ef4444' } } }], + }, + documentStyle: {}, + }); + const desktopSnapshot = createSnapshot(); + const mobileSnapshot = createSnapshot(); + + checkAndSetRenderStyleConfig(desktop, desktopSnapshot); + checkAndSetRenderStyleConfig(mobile, mobileSnapshot); + + expect(desktopSnapshot.documentStyle.renderConfig?.isRenderStyle).toBe(BooleanNumber.FALSE); + expect(mobileSnapshot.documentStyle.renderConfig?.isRenderStyle).toBe(BooleanNumber.TRUE); + expect(mobileSnapshot.body?.textRuns).toEqual([ + { st: 0, ed: 4, ts: { bl: BooleanNumber.TRUE, cl: { rgb: '#ef4444' } } }, + ]); + }); + it('renders formula reference styles in the formula bar when the formula bar is focused', () => { const { controller } = createController({ isFocusFxBar: true }); const cellEditorSnapshot: IDocumentData = { diff --git a/packages/sheets-ui/src/controllers/editor/__tests__/formula-editor.controller.spec.ts b/packages/sheets-ui/src/controllers/editor/__tests__/formula-editor.controller.spec.ts index 4423d2e631..423bcbf6f1 100644 --- a/packages/sheets-ui/src/controllers/editor/__tests__/formula-editor.controller.spec.ts +++ b/packages/sheets-ui/src/controllers/editor/__tests__/formula-editor.controller.spec.ts @@ -29,7 +29,7 @@ import { SetCellEditVisibleOperation } from '../../../commands/operations/cell-e import { FormulaEditorController } from '../formula-editor.controller'; function createController() { - const fxBtnClick$ = new Subject(); + const fxBtnClick$ = new Subject(); const position = { width: 240, height: 40 }; let focusEditorId = DOCS_NORMAL_EDITOR_UNIT_ID_KEY; const contextValues = new Map([ @@ -121,7 +121,7 @@ describe('FormulaEditorController business methods', () => { vi.stubGlobal('requestAnimationFrame', raf); controller._listenFxBtnClick(); - fxBtnClick$.next(); + fxBtnClick$.next(false); expect(controller._univerInstanceService.setCurrentUnitForType).toHaveBeenCalledWith(DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY); expect(controller._commandService.syncExecuteCommand).toHaveBeenCalledWith(SetCellEditVisibleOperation.id, { @@ -144,6 +144,34 @@ describe('FormulaEditorController business methods', () => { vi.stubGlobal('requestAnimationFrame', originalRequestAnimationFrame); }); + it('enters formula mode from an active mobile formula bar', () => { + const { contextValues, controller, fxBtnClick$ } = createController(); + const originalRequestAnimationFrame = globalThis.requestAnimationFrame; + contextValues.set(EDITOR_ACTIVATED, true); + controller._editorBridgeService.isVisible.mockReturnValue({ visible: true }); + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + callback(0); + return 1; + }); + + controller._listenFxBtnClick(); + fxBtnClick$.next(true); + + expect(controller._commandService.syncExecuteCommand).not.toHaveBeenCalledWith( + SetCellEditVisibleOperation.id, + expect.anything() + ); + expect(controller._commandService.syncExecuteCommand).toHaveBeenCalledWith(CoverContentCommand.id, { + unitId: DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY, + body: { dataStream: '=SUM(A1:A3)' }, + segmentId: '', + textRanges: [{ startOffset: 11, endOffset: 11 }], + }); + expect(controller._contextService.setContextValue).toHaveBeenCalledWith(FOCUSING_FX_BAR_EDITOR, true); + + vi.stubGlobal('requestAnimationFrame', originalRequestAnimationFrame); + }); + it('keeps formula bar focus while formula range picking is active', () => { const { contextValues, controller, setFocusEditorId } = createController(); contextValues.set(FOCUSING_FX_BAR_EDITOR, true); diff --git a/packages/sheets-ui/src/controllers/editor/data-sync.controller.ts b/packages/sheets-ui/src/controllers/editor/data-sync.controller.ts index 1cccb72985..fe87c7562a 100644 --- a/packages/sheets-ui/src/controllers/editor/data-sync.controller.ts +++ b/packages/sheets-ui/src/controllers/editor/data-sync.controller.ts @@ -43,6 +43,7 @@ import { ReplaceSnapshotCommand } from '@univerjs/docs-ui'; import { DeviceInputEventType, IRenderManagerService } from '@univerjs/engine-render'; import { MoveRangeMutation, RangeProtectionRuleModel, SetRangeValuesMutation, WorksheetProtectionRuleModel } from '@univerjs/sheets'; import { skip } from 'rxjs'; +import { MOBILE_SHEET_FX_EDITOR } from '../../consts/mobile-context'; import { IEditorBridgeService } from '../../services/editor-bridge.service'; import { IFormulaEditorManagerService } from '../../services/editor/formula-editor-manager.service'; import { FormulaEditorController } from './formula-editor.controller'; @@ -345,6 +346,8 @@ export class EditorDataSyncController extends Disposable { if (isFormulaBar) { snapshot.documentStyle = { ...formulaEditorStyle, + pageSize: { ...formulaEditorStyle.pageSize }, + renderConfig: { ...formulaEditorStyle.renderConfig }, textStyle: { cl: { rgb: this._themeService.getColorFromTheme('gray.900'), @@ -374,7 +377,10 @@ export class EditorDataSyncController extends Disposable { const isFormula = (snapshot.body?.dataStream ?? '').startsWith('='); if (!isFormula) { - renderConfig.isRenderStyle = isFormulaBar ? BooleanNumber.FALSE : BooleanNumber.TRUE; + renderConfig.isRenderStyle = shouldRenderSourceStyle( + isFormulaBar, + this._contextService.getContextValue(MOBILE_SHEET_FX_EDITOR) + ); return; } @@ -393,3 +399,7 @@ export class EditorDataSyncController extends Disposable { return newParagraphs; } } + +function shouldRenderSourceStyle(isFormulaBar: boolean, mobile: boolean): BooleanNumber { + return isFormulaBar && !mobile ? BooleanNumber.FALSE : BooleanNumber.TRUE; +} diff --git a/packages/sheets-ui/src/controllers/editor/editing.render-controller.ts b/packages/sheets-ui/src/controllers/editor/editing.render-controller.ts index de63510b51..c3c4459b73 100644 --- a/packages/sheets-ui/src/controllers/editor/editing.render-controller.ts +++ b/packages/sheets-ui/src/controllers/editor/editing.render-controller.ts @@ -40,6 +40,7 @@ import type { import type { IUniverSheetsUIConfig } from '../../config/config'; import type { ICellEditorState, IEditorBridgeServiceVisibleParam } from '../../services/editor-bridge.service'; import { + BooleanNumber, CellValueType, createParagraphId, DEFAULT_EMPTY_DOCUMENT_VALUE, @@ -103,7 +104,7 @@ import { SheetInterceptorService, SheetsSelectionsService, } from '@univerjs/sheets'; -import { KeyCode, MetaKeys } from '@univerjs/ui'; +import { DISABLE_AUTO_FOCUS_KEY, KeyCode, MetaKeys } from '@univerjs/ui'; import { distinctUntilChanged, filter } from 'rxjs'; import { getEditorObject } from '../../basics/editor/get-editor-object'; import { MoveSelectionCommand, MoveSelectionEnterAndTabCommand } from '../../commands/commands/set-selection.command'; @@ -402,18 +403,24 @@ export class EditingRenderController extends Disposable { { unitId: DOCS_NORMAL_EDITOR_UNIT_ID_KEY, subUnitId: DOCS_NORMAL_EDITOR_UNIT_ID_KEY, - } + }, + true, + { shouldFocus: !this._contextService.getContextValue(DISABLE_AUTO_FOCUS_KEY) } ); const cellSelectionRenderManager = this._renderManagerService.getRenderUnitById(DOCS_NORMAL_EDITOR_UNIT_ID_KEY)?.with(DocSelectionRenderService); const formulaSelectionRenderManager = this._renderManagerService.getRenderUnitById(DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY)?.with(DocSelectionRenderService); if (cellSelectionRenderManager?.canFocusing || formulaSelectionRenderManager?.canFocusing) { this._univerInstanceService.setCurrentUnitForType(DOCS_NORMAL_EDITOR_UNIT_ID_KEY); - cellSelectionRenderManager?.activate( - HIDDEN_EDITOR_POSITION, - HIDDEN_EDITOR_POSITION, - true - ); + if (this._contextService.getContextValue(DISABLE_AUTO_FOCUS_KEY)) { + cellSelectionRenderManager?.setInputPosition(HIDDEN_EDITOR_POSITION, HIDDEN_EDITOR_POSITION); + } else { + cellSelectionRenderManager?.activate( + HIDDEN_EDITOR_POSITION, + HIDDEN_EDITOR_POSITION, + true + ); + } } })); } @@ -749,7 +756,13 @@ export class EditingRenderController extends Disposable { } submitCellData(documentDataModel: DocumentDataModel) { - return this._submitEdit(documentDataModel.getSnapshot()); + const snapshot = Tools.deepClone(documentDataModel.getSnapshot()); + const renderConfig = snapshot.documentStyle?.renderConfig; + if (renderConfig) { + renderConfig.isRenderStyle = BooleanNumber.FALSE; + } + + return this._submitEdit(snapshot); } private async _submitEdit(snapshot: IDocumentData, wholeSelection = false) { @@ -1041,9 +1054,10 @@ export function getCellDataByInput( cellData.si = null; cellData.p = snapshot; cellData.t = CellValueType.STRING; - } - // Text format ('@' or '@@@') has the highest priority - else if (cellData.s && isTextFormat(styles?.get(cellData.s)?.n?.pattern)) { + } else if ( + // Text format ('@' or '@@@') has the highest priority + cellData.s && isTextFormat(styles?.get(cellData.s)?.n?.pattern) + ) { // If the style is text format ('@'or '@@@'), the data should be set as a string. cellData.v = newDataStream; cellData.f = null; diff --git a/packages/sheets-ui/src/controllers/editor/formula-editor.controller.ts b/packages/sheets-ui/src/controllers/editor/formula-editor.controller.ts index 7d0cbdfb44..297b8f9b30 100644 --- a/packages/sheets-ui/src/controllers/editor/formula-editor.controller.ts +++ b/packages/sheets-ui/src/controllers/editor/formula-editor.controller.ts @@ -133,12 +133,12 @@ export class FormulaEditorController extends RxDisposable { } private _listenFxBtnClick() { - this._formulaEditorManagerService.fxBtnClick$.pipe(takeUntil(this.dispose$)).subscribe(() => { + this._formulaEditorManagerService.fxBtnClick$.pipe(takeUntil(this.dispose$)).subscribe((forceFormulaMode) => { const isFocusButHidden = this._contextService.getContextValue(FOCUSING_UNIVER_EDITOR) && !this._contextService.getContextValue(EDITOR_ACTIVATED); - if (isFocusButHidden) { + if (isFocusButHidden || forceFormulaMode) { this._univerInstanceService.setCurrentUnitForType(DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY); const formulaEditorDataModel = this._univerInstanceService.getUnit( DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY, diff --git a/packages/sheets-ui/src/controllers/mobile/ui-mobile.controller.ts b/packages/sheets-ui/src/controllers/mobile/ui-mobile.controller.ts index bfbbb06e54..ef2cac938a 100644 --- a/packages/sheets-ui/src/controllers/mobile/ui-mobile.controller.ts +++ b/packages/sheets-ui/src/controllers/mobile/ui-mobile.controller.ts @@ -14,10 +14,23 @@ * limitations under the License. */ -import { Disposable, ICommandService, IConfigService, Inject, Injector, IUniverInstanceService, UniverInstanceType } from '@univerjs/core'; -import { DocSelectionRenderService } from '@univerjs/docs-ui'; -import { getCurrentTypeOfRenderer, IRenderManagerService } from '@univerjs/engine-render'; - +import type { DocumentDataModel, ICommand, Workbook } from '@univerjs/core'; +import { + CommandType, + Direction, + Disposable, + DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY, + ICommandService, + IConfigService, + IContextService, + Inject, + Injector, + IUniverInstanceService, + toDisposable, + UniverInstanceType, +} from '@univerjs/core'; +import { DeleteLeftCommand, DeleteRightCommand, DocSelectionRenderService, EnterCommand, IEditorService } from '@univerjs/docs-ui'; +import { DeviceInputEventType, getCurrentTypeOfRenderer, IRenderManagerService } from '@univerjs/engine-render'; import { SetBoldCommand, SetFontFamilyCommand, @@ -25,13 +38,16 @@ import { SetItalicCommand, SetStrikeThroughCommand, SetUnderlineCommand, + SheetsSelectionsService, } from '@univerjs/sheets'; import { BuiltInUIPart, connectInjector, ILayoutService, IMenuManagerService, + IShortcutService, IUIPartsService, + KeyCode, } from '@univerjs/ui'; import { DeleteRangeMoveLeftConfirmCommand } from '../../commands/commands/delete-range-move-left-confirm.command'; import { DeleteRangeMoveUpConfirmCommand } from '../../commands/commands/delete-range-move-up-confirm.command'; @@ -40,7 +56,9 @@ import { HideColConfirmCommand, HideRowConfirmCommand } from '../../commands/com import { ResetRangeTextColorCommand, SetRangeBoldCommand, + SetRangeFontDecreaseCommand, SetRangeFontFamilyCommand, + SetRangeFontIncreaseCommand, SetRangeFontSizeCommand, SetRangeItalicCommand, SetRangeStrickThroughCommand, @@ -91,9 +109,92 @@ import { SetZoomRatioOperation } from '../../commands/operations/set-zoom-ratio. import { SheetPermissionOpenDialogOperation } from '../../commands/operations/sheet-permission-open-dialog.operation'; import { SheetPermissionOpenPanelOperation } from '../../commands/operations/sheet-permission-open-panel.operation'; import { SidebarDefinedNameOperation } from '../../commands/operations/sidebar-defined-name.operation'; +import { + MOBILE_FORMULA_BAR_SUBMIT_COMMAND_ID, + MOBILE_FX_EDITOR_EXPANDED, + MOBILE_KEYBOARD_VISIBLE, + MOBILE_SHEET_FX_EDITOR, +} from '../../consts/mobile-context'; import { menuSchema } from '../../menu/schema'; +import { IEditorBridgeService } from '../../services/editor-bridge.service'; +import { MobileSheetActionPanel } from '../../views/mobile/action-panel/MobileSheetActionPanel'; +import { MobileFormulaBar } from '../../views/mobile/formula-bar/MobileFormulaBar'; import { MobileSheetBar } from '../../views/mobile/sheet-bar/MobileSheetBar'; import { RenderSheetContent } from '../../views/sheet-container/SheetContainer'; +import { EditingRenderController } from '../editor/editing.render-controller'; + +export const MobileFormulaBarBreakLineCommand: ICommand = { + id: 'sheet.command.mobile-formula-bar-break-line', + type: CommandType.COMMAND, + handler(accessor) { + const editor = accessor.get(IEditorService).getEditor(DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY); + const range = editor?.getSelectionRanges().find((selection) => selection.collapsed); + if (!range) return false; + + // Use the docs Enter pipeline so a list paragraph creates the next list item and an + // empty list item exits the list, matching the native rich-text editor behavior. + return accessor.get(ICommandService).executeCommand(EnterCommand.id); + }, +}; + +export const MobileFormulaBarSubmitCommand: ICommand = { + id: MOBILE_FORMULA_BAR_SUBMIT_COMMAND_ID, + type: CommandType.COMMAND, + async handler(accessor) { + const instanceService = accessor.get(IUniverInstanceService); + const workbook = instanceService.getCurrentUnitOfType(UniverInstanceType.UNIVER_SHEET); + const formulaModel = instanceService.getUnit( + DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY, + UniverInstanceType.UNIVER_DOC + ); + if (!workbook || !formulaModel) return false; + + const render = accessor.get(IRenderManagerService).getRenderUnitById(workbook.getUnitId()); + const editingController = accessor.get(EditingRenderController); + const editorBridgeService = accessor.get(IEditorBridgeService); + if ( + !render + || !editingController + || (editorBridgeService.getEditorDirty() && await editingController.submitCellData(formulaModel) === false) + ) { + return false; + } + + const commandService = accessor.get(ICommandService); + editorBridgeService.enableForceKeepVisible(); + try { + const moved = await commandService.executeCommand(MoveSelectionEnterAndTabCommand.id, { + keycode: KeyCode.ENTER, + direction: Direction.DOWN, + fromCurrentSelection: true, + }); + if (!moved) return false; + } finally { + editorBridgeService.disableForceKeepVisible(); + } + + const primary = accessor.get(SheetsSelectionsService).getCurrentLastSelection()?.primary; + const sheetId = workbook.getActiveSheet()?.getSheetId(); + if (!primary || !sheetId) return false; + + commandService.syncExecuteCommand(SetActivateCellEditOperation.id, { + scene: render.scene, + engine: render.engine, + primary, + unitId: workbook.getUnitId(), + sheetId, + }); + editorBridgeService.refreshEditCellState(); + editorBridgeService.changeEditorDirty(false); + + const endOffset = Math.max(0, (formulaModel.getBody()?.dataStream.length ?? 2) - 2); + accessor.get(IEditorService).getEditor(DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY)?.setSelectionRanges([{ + startOffset: endOffset, + endOffset, + }], false); + return true; + }, +}; export class SheetUIMobileController extends Disposable { constructor( @@ -102,10 +203,16 @@ export class SheetUIMobileController extends Disposable { @ICommandService protected readonly _commandService: ICommandService, @IMenuManagerService protected readonly _menuManagerService: IMenuManagerService, @IUIPartsService protected readonly _uiPartsService: IUIPartsService, - @IConfigService protected readonly _configService: IConfigService + @IShortcutService protected readonly _shortcutService: IShortcutService, + @IConfigService protected readonly _configService: IConfigService, + @IEditorBridgeService protected readonly _editorBridgeService: IEditorBridgeService, + @IContextService protected readonly _contextService: IContextService, + @IRenderManagerService protected readonly _renderManagerService: IRenderManagerService, + @IUniverInstanceService protected readonly _univerInstanceService: IUniverInstanceService ) { super(); + this._contextService.setContextValue(MOBILE_SHEET_FX_EDITOR, true); this._init(); } @@ -114,11 +221,15 @@ export class SheetUIMobileController extends Disposable { this._initMenus(); this._initWorkbenchParts(); this._initFocusHandler(); + this._initKeyboardViewportState(); + this._initMobileFxShortcut(); } private _initCommands(): void { [ ChangeZoomRatioCommand, + MobileFormulaBarBreakLineCommand, + MobileFormulaBarSubmitCommand, ExpandSelectionCommand, MoveSelectionCommand, MoveSelectionEnterAndTabCommand, @@ -142,6 +253,8 @@ export class SheetUIMobileController extends Disposable { SetRangeStrickThroughCommand, SetRangeSubscriptCommand, SetRangeSuperscriptCommand, + SetRangeFontIncreaseCommand, + SetRangeFontDecreaseCommand, SetRangeFontSizeCommand, SetRangeFontFamilyCommand, SetRangeTextColorCommand, @@ -200,25 +313,115 @@ export class SheetUIMobileController extends Disposable { const uiController = this._uiPartsService; const injector = this._injector; - // Use the same FormulaBar implementation as desktop for now. - // this.disposeWithMe(uiController.registerComponent(BuiltInUIPart.HEADER, () => connectInjector(RenderSheetHeader, injector))); - this.disposeWithMe(uiController.registerComponent(BuiltInUIPart.FOOTER, () => connectInjector(MobileSheetBar, injector))); + this.disposeWithMe(uiController.registerComponent(BuiltInUIPart.HEADER, () => connectInjector(MobileSheetBar, injector))); this.disposeWithMe(uiController.registerComponent(BuiltInUIPart.CONTENT, () => connectInjector(RenderSheetContent, injector))); + this.disposeWithMe(uiController.registerComponent(BuiltInUIPart.FOOTER, () => connectInjector(MobileFormulaBar, injector))); + this.disposeWithMe(uiController.registerComponent(BuiltInUIPart.FOOTER, () => connectInjector(MobileSheetActionPanel, injector))); } private _initFocusHandler(): void { this.disposeWithMe( this._layoutService.registerFocusHandler(UniverInstanceType.UNIVER_SHEET, (_unitId: string) => { - // DEBT: `_unitId` is not used hence we cannot support Univer mode now - // TODO@wzhudev: focus is different on mobile devices - - const renderManagerService = this._injector.get(IRenderManagerService); - const instanceService = this._injector.get(IUniverInstanceService); - const currentEditorRender = getCurrentTypeOfRenderer(UniverInstanceType.UNIVER_DOC, instanceService, renderManagerService); - const docSelectionRenderService = currentEditorRender?.with(DocSelectionRenderService); - - docSelectionRenderService?.focus(); + if (this._editorBridgeService.isVisible().visible) this._focusCellEditorInput(); }) ); + this.disposeWithMe(this._editorBridgeService.visible$.subscribe(({ visible }) => { + if (visible) this._focusCellEditorInput(); + })); + } + + private _focusCellEditorInput(): void { + const currentEditorRender = getCurrentTypeOfRenderer( + UniverInstanceType.UNIVER_DOC, + this._univerInstanceService, + this._renderManagerService + ); + const docSelectionRenderService = currentEditorRender?.with(DocSelectionRenderService); + + docSelectionRenderService?.focus(); + } + + private _initKeyboardViewportState(): void { + const visualViewport = window.visualViewport; + let stableHeight = Math.round(window.innerHeight); + let stableWidth = Math.round(window.innerWidth); + let keyboardWasVisible = false; + + const update = () => { + const width = Math.round(window.innerWidth); + const visibleBottom = visualViewport + ? visualViewport.offsetTop + visualViewport.height + : window.innerHeight; + + if (width !== stableWidth) { + stableHeight = Math.round(Math.max(window.innerHeight, visibleBottom)); + stableWidth = width; + } else if (!isMobileKeyboardVisible(stableHeight, visibleBottom)) { + stableHeight = Math.round(Math.max(stableHeight, window.innerHeight, visibleBottom)); + } + + const keyboardVisible = isMobileKeyboardVisible(stableHeight, visibleBottom); + this._contextService.setContextValue(MOBILE_KEYBOARD_VISIBLE, keyboardVisible); + + if ( + keyboardWasVisible && + !keyboardVisible && + !this._contextService.getContextValue(MOBILE_FX_EDITOR_EXPANDED) + ) { + const visibleState = this._editorBridgeService.isVisible(); + if (visibleState.visible) { + this._commandService.executeCommand(SetCellEditVisibleOperation.id, { + ...visibleState, + visible: false, + eventType: DeviceInputEventType.PointerDown, + }); + } + } + + keyboardWasVisible = keyboardVisible; + }; + + update(); + window.addEventListener('resize', update); + visualViewport?.addEventListener('resize', update); + visualViewport?.addEventListener('scroll', update); + this.disposeWithMe(toDisposable(() => { + window.removeEventListener('resize', update); + visualViewport?.removeEventListener('resize', update); + visualViewport?.removeEventListener('scroll', update); + this._contextService.setContextValue(MOBILE_KEYBOARD_VISIBLE, false); + })); + } + + private _initMobileFxShortcut(): void { + const isFormulaEditorEvent = (event: KeyboardEvent) => + event.target instanceof HTMLElement && + event.target.id === `__editor_${DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY}`; + const shortcuts = [ + { + id: MobileFormulaBarSubmitCommand.id, + binding: KeyCode.ENTER, + preconditions: (contextService: IContextService) => + !contextService.getContextValue(MOBILE_FX_EDITOR_EXPANDED), + }, + { + id: MobileFormulaBarBreakLineCommand.id, + binding: KeyCode.ENTER, + preconditions: (contextService: IContextService) => + Boolean(contextService.getContextValue(MOBILE_FX_EDITOR_EXPANDED)), + }, + { id: DeleteLeftCommand.id, binding: KeyCode.BACKSPACE }, + { id: DeleteRightCommand.id, binding: KeyCode.DELETE }, + ]; + + shortcuts.forEach((shortcut) => this.disposeWithMe(this._shortcutService.registerShortcut({ + ...shortcut, + priority: 1000, + eventPreconditions: isFormulaEditorEvent, + }))); } } + +export function isMobileKeyboardVisible(stableHeight: number, visibleBottom: number): boolean { + return stableHeight - visibleBottom > 80; +} diff --git a/packages/sheets-ui/src/controllers/render-controllers/__tests__/editor-bridge-render-business.spec.ts b/packages/sheets-ui/src/controllers/render-controllers/__tests__/editor-bridge-render-business.spec.ts index 8935425fd7..a92ef7c306 100644 --- a/packages/sheets-ui/src/controllers/render-controllers/__tests__/editor-bridge-render-business.spec.ts +++ b/packages/sheets-ui/src/controllers/render-controllers/__tests__/editor-bridge-render-business.spec.ts @@ -21,12 +21,14 @@ import { DOCS_NORMAL_EDITOR_UNIT_ID_KEY, FOCUSING_FX_BAR_EDITOR, FOCUSING_SHEET import { DocSelectionRenderService } from '@univerjs/docs-ui'; import { DeviceInputEventType } from '@univerjs/engine-render'; import { ClearSelectionFormatCommand, SetWorksheetActiveOperation } from '@univerjs/sheets'; +import { DISABLE_AUTO_FOCUS_KEY } from '@univerjs/ui'; import { Subject } from 'rxjs'; import { describe, expect, it, vi } from 'vitest'; 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 { SHEET_VIEW_KEY } from '../../../common/keys'; +import { MOBILE_KEYBOARD_VISIBLE } from '../../../consts/mobile-context'; import { EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE } from '../../../services/sheet-embed-integration.service'; import { EditorBridgeRenderController } from '../editor-bridge.render-controller'; @@ -72,6 +74,8 @@ function createController(options?: { isEmbedRuntimeEvent?: boolean; isEmbedActiveSession?: boolean; focusingSheet?: boolean; + disableAutoFocus?: boolean; + keyboardVisible?: boolean; isEmbedRuntimeEventImpl?: (unitId: string | undefined, target?: EventTarget | null, event?: Event) => boolean; }) { const workbook$ = new Subject(); @@ -100,6 +104,8 @@ function createController(options?: { const contextValues = new Map([ [FOCUSING_SHEET, options?.focusingSheet ?? true], [FOCUSING_FX_BAR_EDITOR, false], + [DISABLE_AUTO_FOCUS_KEY, options?.disableAutoFocus ?? false], + [MOBILE_KEYBOARD_VISIBLE, options?.keyboardVisible ?? false], ]); const focusingSheet$ = new Subject(); const context = { @@ -128,6 +134,7 @@ function createController(options?: { } : null), }; + let editorVisible = options?.editorVisible ?? false; const editorBridgeService = { getEditCellState: vi.fn(() => options?.currentEditCellState ?? null), getEditCellLayout: vi.fn(() => ({ @@ -135,10 +142,20 @@ function createController(options?: { canvasOffset: { left: 30, top: 20 }, })), getEditLocation: vi.fn(() => options?.currentEditLocation ?? null), - isVisible: vi.fn(() => ({ visible: options?.editorVisible ?? false })), + isVisible: vi.fn(() => ({ visible: editorVisible })), isForceKeepVisible: vi.fn(() => options?.forceKeepVisible ?? false), refreshEditCellState: vi.fn(), }; + commandService.syncExecuteCommand.mockImplementation((commandId: string, params?: { visible?: boolean }) => { + if ( + options?.keyboardVisible && + commandId === SetCellEditVisibleOperation.id && + typeof params?.visible === 'boolean' + ) { + editorVisible = params.visible; + } + return true; + }); const controller = new EditorBridgeRenderController( context as any, { @@ -316,6 +333,22 @@ describe('EditorBridgeRenderController business flows', () => { controller.dispose(); }); + it('requires an explicit edit action when mobile auto focus is disabled', () => { + const { commandService, controller, inputBefore$, spreadsheet } = createController({ disableAutoFocus: true }); + + inputBefore$.next({ event: { data: 'A', which: 65 } }); + expect(commandService.syncExecuteCommand).not.toHaveBeenCalledWith(SetCellEditVisibleOperation.id, expect.anything()); + + spreadsheet.onDblclick$.emit({ button: 0 }); + expect(commandService.executeCommand).toHaveBeenCalledWith(SetCellEditVisibleOperation.id, { + visible: true, + eventType: DeviceInputEventType.Dblclick, + unitId: 'unit-1', + }); + + controller.dispose(); + }); + it('moves the hidden input when the user finishes selecting a cell', () => { const { controller, docSelectionRenderService, selectionMoveEnd$ } = createController(); @@ -335,6 +368,41 @@ describe('EditorBridgeRenderController business flows', () => { controller.dispose(); }); + it('commits and reopens editing on the selected cell while the mobile keyboard stays visible', () => { + const { commandService, controller, selectionMoveEnd$, spreadsheet } = createController({ + editorVisible: true, + keyboardVisible: true, + }); + + spreadsheet.onPointerDown$.emit({}); + expect(commandService.syncExecuteCommand).not.toHaveBeenCalled(); + + selectionMoveEnd$.next([{ + primary: { + actualRow: 3, + actualColumn: 4, + startRow: 3, + startColumn: 4, + endRow: 3, + endColumn: 4, + }, + }]); + + expect(commandService.syncExecuteCommand).toHaveBeenNthCalledWith(1, SetCellEditVisibleOperation.id, { + visible: false, + eventType: DeviceInputEventType.PointerDown, + unitId: 'unit-1', + }); + expect(commandService.executeCommand).toHaveBeenCalledWith(SetActivateCellEditOperation.id, expect.anything()); + expect(commandService.syncExecuteCommand).toHaveBeenNthCalledWith(2, SetCellEditVisibleOperation.id, { + visible: true, + eventType: DeviceInputEventType.PointerDown, + unitId: 'unit-1', + }); + + controller.dispose(); + }); + it('opens sheet editing from keyboard input while the sheet is an active embed child session', () => { const { commandService, controller, inputBefore$ } = createController({ focusingSheet: false, diff --git a/packages/sheets-ui/src/controllers/render-controllers/__tests__/mobile-contextmenu.render-controller.spec.ts b/packages/sheets-ui/src/controllers/render-controllers/__tests__/mobile-contextmenu.render-controller.spec.ts deleted file mode 100644 index f9b9448d5f..0000000000 --- a/packages/sheets-ui/src/controllers/render-controllers/__tests__/mobile-contextmenu.render-controller.spec.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** - * 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 { RANGE_TYPE } from '@univerjs/core'; -import { describe, expect, it } from 'vitest'; -import { shouldKeepCurrentSelectionForMobileContextMenu } from '../mobile/mobile-contextmenu.render-controller'; - -describe('mobile context menu helpers', () => { - it('keeps current selection when long press is inside it', () => { - expect(shouldKeepCurrentSelectionForMobileContextMenu([ - { - range: { startRow: 1, endRow: 3, startColumn: 2, endColumn: 4, rangeType: RANGE_TYPE.NORMAL }, - primary: null, - style: null, - }, - ], { - startRow: 2, - endRow: 2, - startColumn: 3, - endColumn: 3, - rangeType: RANGE_TYPE.NORMAL, - })).toBe(true); - }); - - it('replaces selection when long press is outside it', () => { - expect(shouldKeepCurrentSelectionForMobileContextMenu([ - { - range: { startRow: 1, endRow: 3, startColumn: 2, endColumn: 4, rangeType: RANGE_TYPE.NORMAL }, - primary: null, - style: null, - }, - ], { - startRow: 5, - endRow: 5, - startColumn: 5, - endColumn: 5, - rangeType: RANGE_TYPE.NORMAL, - })).toBe(false); - }); -}); diff --git a/packages/sheets-ui/src/controllers/render-controllers/editor-bridge.render-controller.ts b/packages/sheets-ui/src/controllers/render-controllers/editor-bridge.render-controller.ts index 2c563f3bef..777e62a6e7 100644 --- a/packages/sheets-ui/src/controllers/render-controllers/editor-bridge.render-controller.ts +++ b/packages/sheets-ui/src/controllers/render-controllers/editor-bridge.render-controller.ts @@ -29,10 +29,12 @@ import { SetWorksheetActiveOperation, SheetsSelectionsService, } from '@univerjs/sheets'; +import { DISABLE_AUTO_FOCUS_KEY } from '@univerjs/ui'; import { filter, 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 { MOBILE_KEYBOARD_VISIBLE } from '../../consts/mobile-context'; import { IEditorBridgeService } from '../../services/editor-bridge.service'; import { ISheetEmbedRuntimeFocusCoordinator, SHEET_EMBED_RUNTIME_FOCUS_ROLE_ATTRIBUTE } from '../../services/sheet-embed-integration.service'; import { SheetSkeletonManagerService } from '../../services/sheet-skeleton-manager.service'; @@ -89,6 +91,31 @@ export class EditorBridgeRenderController extends RxDisposable implements IRende this._selectionManagerService.selectionMoveStart$ ).subscribe((params) => this._updateEditorPosition(params))); d.add(this._selectionManagerService.selectionMoveEnd$.subscribe((params) => { + if ( + this._contextService.getContextValue(MOBILE_KEYBOARD_VISIBLE) && + !this._editorBridgeService.isForceKeepVisible() + ) { + const primary = params?.[params.length - 1]?.primary; + if (!primary) { + return; + } + + if (this._editorBridgeService.isVisible().visible) { + this._commandService.syncExecuteCommand(SetCellEditVisibleOperation.id, { + visible: false, + eventType: DeviceInputEventType.PointerDown, + unitId: this._context.unitId, + }); + } + this._updateEditorPosition(params); + this._commandService.syncExecuteCommand(SetCellEditVisibleOperation.id, { + visible: true, + eventType: DeviceInputEventType.PointerDown, + unitId: this._context.unitId, + }); + return; + } + this._updateEditorPosition(params); if (params?.[params.length - 1]?.primary) { this._updateInputPosition(); @@ -220,6 +247,9 @@ export class EditorBridgeRenderController extends RxDisposable implements IRende 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; } @@ -250,6 +280,7 @@ export class EditorBridgeRenderController extends RxDisposable implements IRende 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) || @@ -318,6 +349,10 @@ export class EditorBridgeRenderController extends RxDisposable implements IRende } private _tryHideEditor(evt?: Event | { target?: EventTarget | null; clientX?: number; clientY?: number; x?: number; y?: number }) { + if (this._contextService.getContextValue(MOBILE_KEYBOARD_VISIBLE)) { + return; + } + // 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()) { diff --git a/packages/sheets-ui/src/controllers/render-controllers/mobile/__tests__/mobile-contextmenu.render-controller.spec.ts b/packages/sheets-ui/src/controllers/render-controllers/mobile/__tests__/mobile-contextmenu.render-controller.spec.ts new file mode 100644 index 0000000000..f4f493442f --- /dev/null +++ b/packages/sheets-ui/src/controllers/render-controllers/mobile/__tests__/mobile-contextmenu.render-controller.spec.ts @@ -0,0 +1,175 @@ +/** + * 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. + */ + +// @vitest-environment jsdom + +import { RANGE_TYPE } from '@univerjs/core'; +import { SelectionMoveType, SheetsSelectionsService } from '@univerjs/sheets'; +import { ContextMenuService, DesktopLayoutService, IContextMenuService, ILayoutService } from '@univerjs/ui'; +import { describe, expect, it, vi } from 'vitest'; +import { createRenderTestBed } from '../../__tests__/render-test-bed'; +import { + SheetContextMenuMobileRenderController, + shouldKeepCurrentSelectionForMobileContextMenu, +} from '../mobile-contextmenu.render-controller'; + +function createEventSubject() { + const listeners: Array<() => void> = []; + return { + subscribeEvent: (listener: () => void) => { + listeners.push(listener); + return { dispose: vi.fn() }; + }, + emit: () => listeners.forEach((listener) => listener()), + }; +} + +describe('mobile context menu helpers', () => { + it('opens on a second tap inside the current selection', () => { + expect(shouldKeepCurrentSelectionForMobileContextMenu([ + { + range: { startRow: 1, endRow: 3, startColumn: 2, endColumn: 4, rangeType: RANGE_TYPE.NORMAL }, + primary: null, + style: null, + }, + ], { + startRow: 2, + endRow: 2, + startColumn: 3, + endColumn: 3, + rangeType: RANGE_TYPE.NORMAL, + })).toBe(true); + }); + + it('keeps the menu closed when tapping outside the current selection', () => { + expect(shouldKeepCurrentSelectionForMobileContextMenu([ + { + range: { startRow: 1, endRow: 3, startColumn: 2, endColumn: 4, rangeType: RANGE_TYPE.NORMAL }, + primary: null, + style: null, + }, + ], { + startRow: 5, + endRow: 5, + startColumn: 5, + endColumn: 5, + rangeType: RANGE_TYPE.NORMAL, + })).toBe(false); + }); + + it('delays the selected-tap menu so a double-tap remains available for editing', async () => { + vi.useFakeTimers(); + const contentElement = document.createElement('div'); + const canvas = document.createElement('canvas'); + contentElement.appendChild(canvas); + document.body.appendChild(contentElement); + + const triggerContextMenu = vi.fn(); + const onDblclick$ = createEventSubject(); + const testBed = createRenderTestBed({ + dependencies: [ + [IContextMenuService, { useClass: ContextMenuService }], + [ILayoutService, { useClass: DesktopLayoutService }], + ], + }); + const { context, injector, scene, sheet, skeleton } = testBed; + const layoutRegistration = injector.get(ILayoutService).registerContentElement(contentElement); + const contextMenuRegistration = injector.get(IContextMenuService).registerContextMenuHandler({ + visible: false, + handleContextMenu: triggerContextMenu, + hideContextMenu: vi.fn(), + }); + const worksheet = sheet.getActiveSheet(); + const selectionManagerService = injector.get(SheetsSelectionsService); + selectionManagerService.setSelections( + sheet.getUnitId(), + worksheet.getSheetId(), + [{ + range: { startRow: 1, endRow: 2, startColumn: 1, endColumn: 2 }, + primary: null, + style: null, + }], + SelectionMoveType.MOVE_END + ); + Object.assign(scene, { pick: () => null }); + Object.assign(skeleton, { + getCellWithCoordByOffset: () => ({ + actualColumn: 1, + actualRow: 1, + endX: 200, + endY: 40, + isMerged: false, + isMergedMainCell: false, + mergeInfo: { + endColumn: 1, + endRow: 1, + endX: 200, + endY: 40, + startColumn: 1, + startRow: 1, + startX: 100, + startY: 20, + }, + startX: 100, + startY: 20, + }), + }); + const mainComponent = context.mainComponent; + if (!mainComponent) throw new Error('Expected the sheet render component to be available.'); + Object.assign(mainComponent, { onDblclick$ }); + testBed.renderManagerService.removeRender(sheet.getUnitId()); + const controller = injector.createInstance(SheetContextMenuMobileRenderController, context); + canvas.addEventListener('pointerup', (event) => event.stopPropagation()); + + const dispatchPointer = (type: string) => { + const event = new Event(type, { bubbles: true, cancelable: true }); + Object.defineProperties(event, { + isPrimary: { value: true }, + clientX: { value: 20 }, + clientY: { value: 20 }, + }); + canvas.dispatchEvent(event); + }; + dispatchPointer('pointerdown'); + expect(selectionManagerService.getCurrentLastSelection()?.range).toEqual({ + startRow: 1, + endRow: 2, + startColumn: 1, + endColumn: 2, + }); + dispatchPointer('pointerup'); + + expect(triggerContextMenu).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(500); + expect(triggerContextMenu).toHaveBeenCalledOnce(); + + triggerContextMenu.mockClear(); + dispatchPointer('pointerdown'); + dispatchPointer('pointerup'); + dispatchPointer('pointerdown'); + dispatchPointer('pointerup'); + onDblclick$.emit(); + await vi.advanceTimersByTimeAsync(500); + expect(triggerContextMenu).not.toHaveBeenCalled(); + + controller.dispose(); + contextMenuRegistration.dispose(); + layoutRegistration.dispose(); + contentElement.remove(); + testBed.univer.dispose(); + vi.useRealTimers(); + }); +}); diff --git a/packages/sheets-ui/src/controllers/render-controllers/mobile/__tests__/mobile-scroll.render-controller.spec.ts b/packages/sheets-ui/src/controllers/render-controllers/mobile/__tests__/mobile-scroll.render-controller.spec.ts index f8dff40c1c..29c7ddfcbe 100644 --- a/packages/sheets-ui/src/controllers/render-controllers/mobile/__tests__/mobile-scroll.render-controller.spec.ts +++ b/packages/sheets-ui/src/controllers/render-controllers/mobile/__tests__/mobile-scroll.render-controller.spec.ts @@ -14,6 +14,8 @@ * limitations under the License. */ +// @vitest-environment jsdom + import { ICommandService, RANGE_TYPE } from '@univerjs/core'; import { SHEET_VIEWPORT_KEY } from '@univerjs/engine-render'; import { Subject } from 'rxjs'; @@ -24,9 +26,32 @@ import { SheetScrollManagerService } from '../../../../services/scroll-manager.s import { createRenderTestBed } from '../../__tests__/render-test-bed'; import { MobileSheetsScrollRenderController } from '../mobile-scroll.render-controller'; +function dispatchTouchEvent( + listeners: Map, + type: string, + touches: Array<{ clientX: number; clientY: number }>, + preventDefault = vi.fn() +): void { + const listener = listeners.get(type); + if (listener == null) { + throw new Error(`Missing ${type} listener`); + } + + const event = new Event(type); + Object.defineProperties(event, { + preventDefault: { value: preventDefault }, + touches: { value: touches }, + }); + listener(event); +} + function createController() { const rawScrollInfo$ = new Subject(); const validViewportScrollInfo$ = new Subject(); + const transformerChangeStart$ = new Subject(); + const transformerChangeEnd$ = new Subject(); + let sceneObjectsEvented = true; + let sceneOverlayOwnsTouch = false; const scrollStates = new Map(); const scrollManagerService = { rawScrollInfo$, @@ -72,9 +97,12 @@ function createController() { ySplit: 0, xSplit: 0, } as any); + const canvasListeners = new Map(); const canvasElement = { parentElement: document.createElement('div'), - addEventListener: vi.fn(), + addEventListener: vi.fn((type: string, listener: EventListener) => { + canvasListeners.set(type, listener); + }), removeEventListener: vi.fn(), getBoundingClientRect: vi.fn(() => ({ left: 0, top: 0 })), }; @@ -83,11 +111,27 @@ function createController() { width: 800, height: 600, } as any); + Object.defineProperty(scene, 'getTransformerByCreate', { + configurable: true, + value: () => ({ + changeStart$: transformerChangeStart$, + changeEnd$: transformerChangeEnd$, + }), + }); + Object.defineProperty(scene, 'objectsEvented', { + configurable: true, + get: () => sceneObjectsEvented, + }); + Object.defineProperty(scene, 'pick', { + configurable: true, + value: () => sceneOverlayOwnsTouch ? scene : null, + }); const controller = injector.createInstance(MobileSheetsScrollRenderController, context as any); return { canvasElement, + canvasListeners, controller, executeCommand, rawScrollInfo$, @@ -98,7 +142,15 @@ function createController() { sheet, sheetSkeletonManagerService: testBed.sheetSkeletonManagerService, syncExecuteCommand, + setSceneObjectsEvented: (value: boolean) => { + sceneObjectsEvented = value; + }, + setSceneOverlayOwnsTouch: (value: boolean) => { + sceneOverlayOwnsTouch = value; + }, testBed, + transformerChangeEnd$, + transformerChangeStart$, validViewportScrollInfo$, viewportMain, }; @@ -287,4 +339,57 @@ describe('MobileSheetsScrollRenderController', () => { now.mockRestore(); testBed.univer.dispose(); }); + + it('does not pan the sheet when a canvas overlay owns the touch gesture', () => { + const { canvasListeners, executeCommand, setSceneOverlayOwnsTouch, testBed } = createController(); + setSceneOverlayOwnsTouch(true); + + dispatchTouchEvent(canvasListeners, 'touchstart', [{ clientX: 120, clientY: 120 }]); + dispatchTouchEvent(canvasListeners, 'touchmove', [{ clientX: 90, clientY: 80 }]); + + expect(executeCommand).not.toHaveBeenCalledWith(SetScrollRelativeCommand.id, expect.anything()); + testBed.univer.dispose(); + }); + + it('yields an active touch gesture to the drawing transformer', () => { + const { canvasListeners, executeCommand, testBed, transformerChangeEnd$, transformerChangeStart$ } = createController(); + const preventDefault = vi.fn(); + const now = vi.spyOn(performance, 'now'); + now.mockReturnValueOnce(0).mockReturnValueOnce(16).mockReturnValueOnce(32); + + dispatchTouchEvent(canvasListeners, 'touchstart', [{ clientX: 120, clientY: 120 }], preventDefault); + transformerChangeStart$.next({}); + dispatchTouchEvent(canvasListeners, 'touchmove', [{ clientX: 90, clientY: 80 }], preventDefault); + + expect(preventDefault).toHaveBeenCalled(); + expect(executeCommand).not.toHaveBeenCalledWith(SetScrollRelativeCommand.id, expect.anything()); + + dispatchTouchEvent(canvasListeners, 'touchend', []); + transformerChangeEnd$.next({}); + dispatchTouchEvent(canvasListeners, 'touchstart', [{ clientX: 120, clientY: 120 }], preventDefault); + dispatchTouchEvent(canvasListeners, 'touchmove', [{ clientX: 90, clientY: 80 }], preventDefault); + + expect(executeCommand).toHaveBeenCalledWith(SetScrollRelativeCommand.id, { + offsetX: 30, + offsetY: 40, + }); + + now.mockRestore(); + testBed.univer.dispose(); + }); + + it('yields an active touch gesture to shape adjustment controls', () => { + const { canvasListeners, executeCommand, setSceneObjectsEvented, testBed } = createController(); + const preventDefault = vi.fn(); + + dispatchTouchEvent(canvasListeners, 'touchstart', [{ clientX: 120, clientY: 120 }], preventDefault); + setSceneObjectsEvented(false); + dispatchTouchEvent(canvasListeners, 'touchmove', [{ clientX: 90, clientY: 80 }], preventDefault); + + expect(preventDefault).toHaveBeenCalled(); + expect(executeCommand).not.toHaveBeenCalledWith(SetScrollRelativeCommand.id, expect.anything()); + + setSceneObjectsEvented(true); + testBed.univer.dispose(); + }); }); diff --git a/packages/sheets-ui/src/controllers/render-controllers/mobile/mobile-contextmenu.render-controller.ts b/packages/sheets-ui/src/controllers/render-controllers/mobile/mobile-contextmenu.render-controller.ts index cbe1521225..31955c2d1d 100644 --- a/packages/sheets-ui/src/controllers/render-controllers/mobile/mobile-contextmenu.render-controller.ts +++ b/packages/sheets-ui/src/controllers/render-controllers/mobile/mobile-contextmenu.render-controller.ts @@ -15,19 +15,19 @@ */ import type { ICellWithCoord, IRange, Nullable, Workbook } from '@univerjs/core'; -import type { IPointerEvent, IRenderContext, IRenderModule, Viewport } from '@univerjs/engine-render'; +import type { IRenderContext, IRenderModule, Viewport } from '@univerjs/engine-render'; import type { ISelectionWithStyle } from '@univerjs/sheets'; -import { Disposable, IContextService, Inject, RANGE_TYPE, Rectangle, toDisposable } from '@univerjs/core'; +import { Disposable, IContextService, Inject, Rectangle, toDisposable } from '@univerjs/core'; import { IRenderManagerService, SHEET_VIEWPORT_KEY, Vector2 } from '@univerjs/engine-render'; -import { convertPrimaryWithCoordToPrimary, convertSelectionDataToRange, SelectionMoveType, SheetsSelectionsService } from '@univerjs/sheets'; +import { convertSelectionDataToRange, SheetsSelectionsService } from '@univerjs/sheets'; import { ContextMenuPosition, IContextMenuService, ILayoutService } from '@univerjs/ui'; import { MOBILE_EXPANDING_SELECTION, MOBILE_PINCH_ZOOMING } from '../../../consts/mobile-context'; import { ISheetSelectionRenderService } from '../../../services/selection/base-selection-render.service'; import { SELECTION_MANAGER_KEY } from '../../../services/selection/selection-control'; import { SheetSkeletonManagerService } from '../../../services/sheet-skeleton-manager.service'; -const LONG_PRESS_DURATION = 500; -const LONG_PRESS_MOVE_THRESHOLD = 10; +const TAP_MOVE_THRESHOLD = 10; +const TAP_MENU_DELAY = 500; const MAIN_AREA_VIEWPORT_KEYS = new Set([ SHEET_VIEWPORT_KEY.VIEW_MAIN, SHEET_VIEWPORT_KEY.VIEW_MAIN_LEFT, @@ -56,12 +56,11 @@ interface ITouchPosition { offsetY: number; } -interface ILongPressState { - longPressTimer: ReturnType | null; +interface ITapState { activeTouch: Nullable; - longPressTriggered: boolean; + menuTimer: Nullable>; + shouldOpenMenu: boolean; selectionSnapshot: ISelectionWithStyle[]; - shouldRestoreSelectionSnapshot: boolean; } function isSelectionObjectKey(objectKey?: string | null): boolean { @@ -73,9 +72,8 @@ export function shouldKeepCurrentSelectionForMobileContextMenu(currentSelections } /** - * On mobile devices, the context menu pops up after a native touch long press. - * This bypasses the older "selection event -> menu" chain, which was unreliable - * when the touch target was the selection overlay itself. + * On mobile devices, tapping inside the current selection a second time opens + * the context menu. Tapping elsewhere remains a normal selection gesture. * * @ignore */ @@ -100,54 +98,59 @@ export class SheetContextMenuMobileRenderController extends Disposable implement return; } - const state = this._createLongPressState(); - const handleTouchStart = (event: TouchEvent) => this._handleTouchStart(contentElement, state, event); - const handleTouchMove = (event: TouchEvent) => this._handleTouchMove(contentElement, state, event); - const handleTouchEnd = (event: TouchEvent) => this._handleTouchEnd(state, event); + const state = this._createTapState(); + const handlePointerDown = (event: PointerEvent) => this._handlePointerDown(contentElement, state, event); + const handlePointerMove = (event: PointerEvent) => this._handlePointerMove(contentElement, state, event); + const handlePointerUp = (event: PointerEvent) => this._handlePointerUp(state, event); + const handlePointerCancel = () => this._resetTapState(state); + const dblclickSubscription = this._context.mainComponent?.onDblclick$.subscribeEvent(() => this._cancelPendingMenu(state)); + if (dblclickSubscription) { + this.disposeWithMe(dblclickSubscription); + } - contentElement.addEventListener('touchstart', handleTouchStart, { passive: true }); - contentElement.addEventListener('touchmove', handleTouchMove, { passive: true }); - contentElement.addEventListener('touchend', handleTouchEnd, { passive: false }); - contentElement.addEventListener('touchcancel', handleTouchEnd, { passive: false }); + contentElement.addEventListener('pointerdown', handlePointerDown, true); + contentElement.addEventListener('pointermove', handlePointerMove, true); + contentElement.addEventListener('pointerup', handlePointerUp, true); + contentElement.addEventListener('pointercancel', handlePointerCancel, true); this.disposeWithMe(toDisposable(() => { - this._clearLongPressTimer(state); - contentElement.removeEventListener('touchstart', handleTouchStart); - contentElement.removeEventListener('touchmove', handleTouchMove); - contentElement.removeEventListener('touchend', handleTouchEnd); - contentElement.removeEventListener('touchcancel', handleTouchEnd); + contentElement.removeEventListener('pointerdown', handlePointerDown, true); + contentElement.removeEventListener('pointermove', handlePointerMove, true); + contentElement.removeEventListener('pointerup', handlePointerUp, true); + contentElement.removeEventListener('pointercancel', handlePointerCancel, true); + this._cancelPendingMenu(state); })); } - private _createLongPressState(): ILongPressState { + private _createTapState(): ITapState { return { - longPressTimer: null, activeTouch: null, - longPressTriggered: false, + menuTimer: null, + shouldOpenMenu: false, selectionSnapshot: [], - shouldRestoreSelectionSnapshot: false, }; } - private _clearLongPressTimer(state: ILongPressState): void { - if (state.longPressTimer) { - clearTimeout(state.longPressTimer); - state.longPressTimer = null; - } - } - - private _resetLongPressState(state: ILongPressState): void { - this._clearLongPressTimer(state); + private _resetTapState(state: ITapState): void { state.activeTouch = null; + state.shouldOpenMenu = false; } - private _getTouchOffset(contentElement: HTMLElement, touch: Touch): ITouchPosition { + private _cancelPendingMenu(state: ITapState): void { + if (state.menuTimer == null) { + return; + } + clearTimeout(state.menuTimer); + state.menuTimer = null; + } + + private _getPointerOffset(contentElement: HTMLElement, event: PointerEvent): ITouchPosition { const rect = contentElement.getBoundingClientRect(); return { - clientX: touch.clientX, - clientY: touch.clientY, - offsetX: touch.clientX - rect.left, - offsetY: touch.clientY - rect.top, + clientX: event.clientX, + clientY: event.clientY, + offsetX: event.clientX - rect.left, + offsetY: event.clientY - rect.top, }; } @@ -184,7 +187,7 @@ export class SheetContextMenuMobileRenderController extends Disposable implement return selectionRenderService?.getSelectionControls().map((control) => convertSelectionDataToRange(control.getValue())) ?? []; } - private _cloneSelections(selections: ISelectionWithStyle[]): ISelectionWithStyle[] { + private _cloneSelections(selections: Readonly): ISelectionWithStyle[] { return selections.map((selection) => ({ range: { ...selection.range }, primary: selection.primary ? { ...selection.primary } : selection.primary, @@ -198,137 +201,71 @@ export class SheetContextMenuMobileRenderController extends Disposable implement return this._cloneSelections(renderedSelections); } - return this._cloneSelections(this._selectionManagerService.getCurrentSelections() as ISelectionWithStyle[]); - } - - private _restoreSelectionSnapshot(selectionSnapshot: ISelectionWithStyle[]): void { - const worksheet = this._context.unit.getActiveSheet(); - if (!worksheet || !selectionSnapshot.length) { - return; - } - - this._selectionManagerService.setSelections( - this._context.unitId, - worksheet.getSheetId(), - this._cloneSelections(selectionSnapshot), - SelectionMoveType.MOVE_END - ); - } - - private _replaceSelectionWithTargetCell(targetCell: ICellWithCoord): void { - const worksheet = this._context.unit.getActiveSheet(); - if (!worksheet) { - return; - } - - this._selectionManagerService.setSelections(this._context.unitId, worksheet.getSheetId(), [{ - range: { - startRow: targetCell.mergeInfo.startRow, - endRow: targetCell.mergeInfo.endRow, - startColumn: targetCell.mergeInfo.startColumn, - endColumn: targetCell.mergeInfo.endColumn, - rangeType: RANGE_TYPE.NORMAL, - unitId: this._context.unitId, - sheetId: worksheet.getSheetId(), - }, - primary: convertPrimaryWithCoordToPrimary(targetCell), - style: null, - }], SelectionMoveType.MOVE_END); + return this._cloneSelections(this._selectionManagerService.getCurrentSelections()); } private _openMenu(clientX: number, clientY: number): void { - this._contextMenuService.triggerContextMenu({ - clientX, - clientY, - preventDefault: () => {}, - stopPropagation: () => {}, - } as unknown as IPointerEvent, ContextMenuPosition.MAIN_AREA); + const event = new MouseEvent('contextmenu', { clientX, clientY }); + this._contextMenuService.triggerContextMenu(event, ContextMenuPosition.MAIN_AREA, { unitId: this._context.unitId }); } - private _triggerLongPressMenu(state: ILongPressState): void { - if (!state.activeTouch || this._contextMenuService.visible) { + private _handlePointerDown(contentElement: HTMLElement, state: ITapState, event: PointerEvent): void { + this._cancelPendingMenu(state); + if (this._contextMenuService.visible) { + this._contextMenuService.hideContextMenu(); + this._resetTapState(state); return; } - if (this._contextService.getContextValue(MOBILE_PINCH_ZOOMING) || this._contextService.getContextValue(MOBILE_EXPANDING_SELECTION)) { + if (!event.isPrimary || this._contextService.getContextValue(MOBILE_PINCH_ZOOMING) || this._contextService.getContextValue(MOBILE_EXPANDING_SELECTION)) { + this._resetTapState(state); return; } - const targetCell = this._getTargetCellByOffset(state.activeTouch.offsetX, state.activeTouch.offsetY); + const touch = this._getPointerOffset(contentElement, event); + const targetCell = this._getTargetCellByOffset(touch.offsetX, touch.offsetY); if (!targetCell) { + this._resetTapState(state); return; } - const pickedObject = this._context.scene.pick(Vector2.FromArray([state.activeTouch.offsetX, state.activeTouch.offsetY])) as { oKey?: string } | null; - const snapshotRanges = state.selectionSnapshot.map((selection) => selection.range); - const shouldKeepSelection = isSelectionObjectKey(pickedObject?.oKey) - || shouldKeepCurrentSelectionForMobileContextMenu(snapshotRanges, targetCell.mergeInfo); - - state.longPressTriggered = true; - state.shouldRestoreSelectionSnapshot = shouldKeepSelection; - - if (shouldKeepSelection) { - this._restoreSelectionSnapshot(state.selectionSnapshot); - queueMicrotask(() => this._restoreSelectionSnapshot(state.selectionSnapshot)); - } - - this._openMenu(state.activeTouch.clientX, state.activeTouch.clientY); - - if (!shouldKeepSelection) { - queueMicrotask(() => this._replaceSelectionWithTargetCell(targetCell)); - } - } - - private _handleTouchStart(contentElement: HTMLElement, state: ILongPressState, event: TouchEvent): void { - if (event.touches.length !== 1 || this._contextMenuService.visible) { - this._resetLongPressState(state); - return; - } - - if (this._contextService.getContextValue(MOBILE_PINCH_ZOOMING) || this._contextService.getContextValue(MOBILE_EXPANDING_SELECTION)) { - this._resetLongPressState(state); - return; - } - - const touch = this._getTouchOffset(contentElement, event.touches[0]); - if (!this._getTargetCellByOffset(touch.offsetX, touch.offsetY)) { - this._resetLongPressState(state); - return; - } - - state.longPressTriggered = false; - state.shouldRestoreSelectionSnapshot = false; state.selectionSnapshot = this._getSelectionSnapshot(); state.activeTouch = touch; - this._clearLongPressTimer(state); - state.longPressTimer = setTimeout(() => this._triggerLongPressMenu(state), LONG_PRESS_DURATION); + const pickedObject = this._context.scene.pick(Vector2.FromArray([touch.offsetX, touch.offsetY])); + state.shouldOpenMenu = isSelectionObjectKey(pickedObject && 'oKey' in pickedObject ? pickedObject.oKey : undefined) + || shouldKeepCurrentSelectionForMobileContextMenu(state.selectionSnapshot, targetCell.mergeInfo); } - private _handleTouchMove(contentElement: HTMLElement, state: ILongPressState, event: TouchEvent): void { - if (!state.activeTouch || event.touches.length !== 1) { - this._resetLongPressState(state); + private _handlePointerMove(contentElement: HTMLElement, state: ITapState, event: PointerEvent): void { + if (!state.activeTouch || !event.isPrimary) { + this._resetTapState(state); return; } - const touch = this._getTouchOffset(contentElement, event.touches[0]); + const touch = this._getPointerOffset(contentElement, event); if ( - Math.abs(touch.offsetX - state.activeTouch.offsetX) > LONG_PRESS_MOVE_THRESHOLD || - Math.abs(touch.offsetY - state.activeTouch.offsetY) > LONG_PRESS_MOVE_THRESHOLD + Math.abs(touch.offsetX - state.activeTouch.offsetX) > TAP_MOVE_THRESHOLD || + Math.abs(touch.offsetY - state.activeTouch.offsetY) > TAP_MOVE_THRESHOLD ) { - this._resetLongPressState(state); + this._resetTapState(state); } } - private _handleTouchEnd(state: ILongPressState, event: TouchEvent): void { - if (state.longPressTriggered) { + private _handlePointerUp(state: ITapState, event: PointerEvent): void { + if ( + state.activeTouch && + state.shouldOpenMenu && + !this._contextService.getContextValue(MOBILE_PINCH_ZOOMING) && + !this._contextService.getContextValue(MOBILE_EXPANDING_SELECTION) + ) { event.preventDefault(); - if (state.shouldRestoreSelectionSnapshot) { - queueMicrotask(() => this._restoreSelectionSnapshot(state.selectionSnapshot)); - } + const { clientX, clientY } = state.activeTouch; + state.menuTimer = setTimeout(() => { + state.menuTimer = null; + this._openMenu(clientX, clientY); + }, TAP_MENU_DELAY); } - state.longPressTriggered = false; - state.shouldRestoreSelectionSnapshot = false; - this._resetLongPressState(state); + this._resetTapState(state); } } diff --git a/packages/sheets-ui/src/controllers/render-controllers/mobile/mobile-scroll.render-controller.ts b/packages/sheets-ui/src/controllers/render-controllers/mobile/mobile-scroll.render-controller.ts index f15b4c9725..8210be12c3 100644 --- a/packages/sheets-ui/src/controllers/render-controllers/mobile/mobile-scroll.render-controller.ts +++ b/packages/sheets-ui/src/controllers/render-controllers/mobile/mobile-scroll.render-controller.ts @@ -29,7 +29,7 @@ import { RANGE_TYPE, toDisposable, } from '@univerjs/core'; -import { IRenderManagerService, SHEET_VIEWPORT_KEY } from '@univerjs/engine-render'; +import { IRenderManagerService, SHEET_VIEWPORT_KEY, Vector2 } from '@univerjs/engine-render'; import { ScrollToCellOperation, SheetsSelectionsService } from '@univerjs/sheets'; import { ScrollCommand, SetScrollRelativeCommand } from '../../../commands/commands/set-scroll.command'; import { ExpandSelectionCommand, MoveSelectionCommand, MoveSelectionEnterAndTabCommand } from '../../../commands/commands/set-selection.command'; @@ -316,9 +316,11 @@ export class MobileSheetsScrollRenderController extends Disposable implements IR const scene = sheetObject.scene; const spreadsheet = sheetObject.spreadsheet; + const transformer = scene.getTransformerByCreate(); const viewportMain = scene.getViewport(SHEET_VIEWPORT_KEY.VIEW_MAIN); const lastTouchPos: IPoint = { x: 0, y: 0 }; let _touchScrolling: boolean = false; + let _drawingTransforming: boolean = false; // Velocity tracking for smooth inertia (iOS-like) const velocity = { x: 0, y: 0 }; @@ -453,6 +455,16 @@ export class MobileSheetsScrollRenderController extends Disposable implements IR velocity.y = 0; }; + this.disposeWithMe(toDisposable(transformer.changeStart$.subscribe(() => { + _drawingTransforming = true; + _touchScrolling = false; + velocityHistory.length = 0; + cancelInertiaAnimation(); + }))); + this.disposeWithMe(toDisposable(transformer.changeEnd$.subscribe(() => { + _drawingTransforming = false; + }))); + const scrollInertia = (currentTime: number) => { if (!viewportMain) return; @@ -719,6 +731,10 @@ export class MobileSheetsScrollRenderController extends Disposable implements IR const handleTouchStart = (e: TouchEvent) => { // Handle pinch-to-zoom (two fingers) cancelInertiaAnimation(); + if (_drawingTransforming || scene.objectsEvented === false) { + e.preventDefault(); + return; + } if (e.touches.length === 2) { const touch1 = e.touches[0]; const touch2 = e.touches[1]; @@ -759,6 +775,12 @@ export class MobileSheetsScrollRenderController extends Disposable implements IR // Only start scrolling if touch is on spreadsheet area if (!isTouchOnSpreadsheet(offsetX, offsetY)) return; + // A drawing, selection handle, or another canvas overlay owns this gesture. + // Native touch listeners bypass the render event propagation chain, so the + // sheet scroller must explicitly yield to the picked overlay object. + const pickedObject = scene.pick(Vector2.FromArray([offsetX, offsetY])); + if (pickedObject && pickedObject !== spreadsheet) return; + lastTouchPos.x = offsetX; lastTouchPos.y = offsetY; lastMoveTime = performance.now(); @@ -774,6 +796,14 @@ export class MobileSheetsScrollRenderController extends Disposable implements IR // eslint-disable-next-line max-lines-per-function const handleTouchMove = (e: TouchEvent) => { + if (_drawingTransforming || scene.objectsEvented === false) { + _touchScrolling = false; + velocityHistory.length = 0; + cancelInertiaAnimation(); + e.preventDefault(); + return; + } + // Handle pinch-to-zoom if (_pinchZooming && e.touches.length === 2) { // Extra protection: ensure inertia is cancelled during zoom @@ -883,6 +913,10 @@ export class MobileSheetsScrollRenderController extends Disposable implements IR }; const handleTouchEnd = (e: TouchEvent) => { + if (e.touches.length === 0) { + _drawingTransforming = false; + } + // Handle pinch zoom end if (_pinchZooming) { if (e.touches.length < 2) { @@ -943,6 +977,7 @@ export class MobileSheetsScrollRenderController extends Disposable implements IR }; const handleTouchCancel = () => { + _drawingTransforming = false; if (_pinchZooming) { _pinchZooming = false; this._contextService.setContextValue(MOBILE_PINCH_ZOOMING, false); diff --git a/packages/sheets-ui/src/locale/ar-SA.ts b/packages/sheets-ui/src/locale/ar-SA.ts index ec98b9522c..4df49bda97 100644 --- a/packages/sheets-ui/src/locale/ar-SA.ts +++ b/packages/sheets-ui/src/locale/ar-SA.ts @@ -115,6 +115,25 @@ const locale: typeof enUS = { rotationUp: 'تدوير لأعلى', rotationDown: 'تدوير لأسفل', }, + mobile: { + insert: 'إدراج', + operation: 'إجراءات', + style: 'نمط', + openTools: 'فتح أدوات الورقة', + closeTools: 'إغلاق أدوات الورقة', + addSheet: 'إضافة ورقة', + sheetMenu: 'فتح قائمة الورقة', + borderColor: 'لون الحدود', + borderStyle: 'نمط الحدود', + recentColors: 'الألوان الأخيرة', + customColor: 'لون مخصص', + applyColor: 'تطبيق اللون', + back: 'رجوع', + formula: 'صيغة', + collapseEditor: 'طي المحرر', + confirm: 'تأكيد', + customFormatDescription: 'إنشاء تنسيقات أرقام مخصصة استنادًا إلى التنسيقات الموجودة.', + }, sheetConfig: { sheetTabs: 'علامات تبويب الأوراق', diff --git a/packages/sheets-ui/src/locale/ca-ES.ts b/packages/sheets-ui/src/locale/ca-ES.ts index ccfd7872f7..ca8f7393ba 100644 --- a/packages/sheets-ui/src/locale/ca-ES.ts +++ b/packages/sheets-ui/src/locale/ca-ES.ts @@ -115,6 +115,25 @@ const locale: typeof enUS = { rotationUp: 'Girar cap amunt', rotationDown: 'Girar cap avall', }, + mobile: { + insert: 'Insereix', + operation: 'Accions', + style: 'Estil', + openTools: 'Obre les eines del full', + closeTools: 'Tanca les eines del full', + addSheet: 'Afegeix un full', + sheetMenu: 'Obre el menú del full', + borderColor: 'Color de la vora', + borderStyle: 'Estil de la vora', + recentColors: 'Colors recents', + customColor: 'Color personalitzat', + applyColor: 'Aplica el color', + back: 'Enrere', + formula: 'Fórmula', + collapseEditor: 'Redueix l’editor', + confirm: 'Confirmar', + customFormatDescription: 'Genera formats de nombre personalitzats basats en els existents.', + }, sheetConfig: { sheetTabs: 'Pestanyes de fulls', diff --git a/packages/sheets-ui/src/locale/de-DE.ts b/packages/sheets-ui/src/locale/de-DE.ts index cf7187c472..88533a8f56 100644 --- a/packages/sheets-ui/src/locale/de-DE.ts +++ b/packages/sheets-ui/src/locale/de-DE.ts @@ -115,6 +115,25 @@ const locale: typeof enUS = { rotationUp: 'Nach oben drehen', rotationDown: 'Nach unten drehen', }, + mobile: { + insert: 'Einfügen', + operation: 'Aktionen', + style: 'Stil', + openTools: 'Tabellenwerkzeuge öffnen', + closeTools: 'Tabellenwerkzeuge schließen', + addSheet: 'Tabelle hinzufügen', + sheetMenu: 'Tabellenmenü öffnen', + borderColor: 'Rahmenfarbe', + borderStyle: 'Rahmenstil', + recentColors: 'Zuletzt verwendete Farben', + customColor: 'Benutzerdefinierte Farbe', + applyColor: 'Farbe anwenden', + back: 'Zurück', + formula: 'Formel', + collapseEditor: 'Editor minimieren', + confirm: 'Bestätigen', + customFormatDescription: 'Benutzerdefinierte Zahlenformate basierend auf vorhandenen Formaten erstellen.', + }, sheetConfig: { sheetTabs: 'Blattregisterkarten', diff --git a/packages/sheets-ui/src/locale/en-US.ts b/packages/sheets-ui/src/locale/en-US.ts index 46efabe736..8bcbc85634 100644 --- a/packages/sheets-ui/src/locale/en-US.ts +++ b/packages/sheets-ui/src/locale/en-US.ts @@ -113,6 +113,25 @@ const locale = { rotationUp: 'Rotate Up', rotationDown: 'Rotate Down', }, + mobile: { + insert: 'Insert', + operation: 'Actions', + style: 'Style', + openTools: 'Open sheet tools', + closeTools: 'Close sheet tools', + addSheet: 'Add sheet', + sheetMenu: 'Open sheet menu', + borderColor: 'Border color', + borderStyle: 'Border style', + recentColors: 'Recent colors', + customColor: 'Custom color', + applyColor: 'Apply color', + back: 'Back', + formula: 'Formula', + collapseEditor: 'Collapse editor', + confirm: 'Confirm', + customFormatDescription: 'Generate custom number formats based on existing formats.', + }, sheetConfig: { sheetTabs: 'Sheet tabs', diff --git a/packages/sheets-ui/src/locale/es-ES.ts b/packages/sheets-ui/src/locale/es-ES.ts index 7429e2317f..c40968ca0c 100644 --- a/packages/sheets-ui/src/locale/es-ES.ts +++ b/packages/sheets-ui/src/locale/es-ES.ts @@ -115,6 +115,25 @@ const locale: typeof enUS = { rotationUp: 'Girar hacia arriba', rotationDown: 'Girar hacia abajo', }, + mobile: { + insert: 'Insertar', + operation: 'Acciones', + style: 'Estilo', + openTools: 'Abrir herramientas de hoja', + closeTools: 'Cerrar herramientas de hoja', + addSheet: 'Añadir hoja', + sheetMenu: 'Abrir menú de hoja', + borderColor: 'Color del borde', + borderStyle: 'Estilo del borde', + recentColors: 'Colores recientes', + customColor: 'Color personalizado', + applyColor: 'Aplicar color', + back: 'Atrás', + formula: 'Fórmula', + collapseEditor: 'Contraer editor', + confirm: 'Confirmar', + customFormatDescription: 'Generar formatos de número personalizados basados en los existentes.', + }, sheetConfig: { sheetTabs: 'Pestañas de hojas', diff --git a/packages/sheets-ui/src/locale/fa-IR.ts b/packages/sheets-ui/src/locale/fa-IR.ts index 44be380233..5323e35ca7 100644 --- a/packages/sheets-ui/src/locale/fa-IR.ts +++ b/packages/sheets-ui/src/locale/fa-IR.ts @@ -115,6 +115,25 @@ const locale: typeof enUS = { rotationUp: 'چرخش بالا', rotationDown: 'چرخش پایین', }, + mobile: { + insert: 'درج', + operation: 'عملیات', + style: 'سبک', + openTools: 'باز کردن ابزارهای برگه', + closeTools: 'بستن ابزارهای برگه', + addSheet: 'افزودن برگه', + sheetMenu: 'باز کردن منوی برگه', + borderColor: 'رنگ حاشیه', + borderStyle: 'سبک حاشیه', + recentColors: 'رنگ‌های اخیر', + customColor: 'رنگ سفارشی', + applyColor: 'اعمال رنگ', + back: 'بازگشت', + formula: 'فرمول', + collapseEditor: 'جمع کردن ویرایشگر', + confirm: 'تایید', + customFormatDescription: 'ایجاد قالب‌بندی‌های عددی سفارشی بر اساس قالب‌بندی‌های موجود.', + }, sheetConfig: { sheetTabs: 'زبانه‌های برگه', diff --git a/packages/sheets-ui/src/locale/fr-FR.ts b/packages/sheets-ui/src/locale/fr-FR.ts index 48578bb0ef..449f00ab88 100644 --- a/packages/sheets-ui/src/locale/fr-FR.ts +++ b/packages/sheets-ui/src/locale/fr-FR.ts @@ -115,6 +115,25 @@ const locale: typeof enUS = { rotationUp: 'Pivoter vers le haut', rotationDown: 'Pivoter vers le bas', }, + mobile: { + insert: 'Insérer', + operation: 'Actions', + style: 'Style', + openTools: 'Ouvrir les outils de la feuille', + closeTools: 'Fermer les outils de la feuille', + addSheet: 'Ajouter une feuille', + sheetMenu: 'Ouvrir le menu de la feuille', + borderColor: 'Couleur de bordure', + borderStyle: 'Style de bordure', + recentColors: 'Couleurs récentes', + customColor: 'Couleur personnalisée', + applyColor: 'Appliquer la couleur', + back: 'Retour', + formula: 'Formule', + collapseEditor: 'Réduire l’éditeur', + confirm: 'Confirmer', + customFormatDescription: 'Générer des formats de nombre personnalisés basés sur des formats existants.', + }, sheetConfig: { sheetTabs: 'Onglets de feuilles', diff --git a/packages/sheets-ui/src/locale/id-ID.ts b/packages/sheets-ui/src/locale/id-ID.ts index 9918d3664f..97313d0e48 100644 --- a/packages/sheets-ui/src/locale/id-ID.ts +++ b/packages/sheets-ui/src/locale/id-ID.ts @@ -115,6 +115,25 @@ const locale: typeof enUS = { rotationUp: 'Putar ke atas', rotationDown: 'Putar ke bawah', }, + mobile: { + insert: 'Sisipkan', + operation: 'Tindakan', + style: 'Gaya', + openTools: 'Buka alat lembar', + closeTools: 'Tutup alat lembar', + addSheet: 'Tambah lembar', + sheetMenu: 'Buka menu lembar', + borderColor: 'Warna batas', + borderStyle: 'Gaya batas', + recentColors: 'Warna terbaru', + customColor: 'Warna khusus', + applyColor: 'Terapkan warna', + back: 'Kembali', + formula: 'Formula', + collapseEditor: 'Ciutkan editor', + confirm: 'Konfirmasi', + customFormatDescription: 'Hasilkan format angka kustom berdasarkan format yang ada.', + }, sheetConfig: { sheetTabs: 'Tab lembar', diff --git a/packages/sheets-ui/src/locale/it-IT.ts b/packages/sheets-ui/src/locale/it-IT.ts index 1a4a0c2fdf..c934774e93 100644 --- a/packages/sheets-ui/src/locale/it-IT.ts +++ b/packages/sheets-ui/src/locale/it-IT.ts @@ -115,6 +115,25 @@ const locale: typeof enUS = { rotationUp: 'Ruota su', rotationDown: 'Ruota giù', }, + mobile: { + insert: 'Inserisci', + operation: 'Azioni', + style: 'Stile', + openTools: 'Apri strumenti del foglio', + closeTools: 'Chiudi strumenti del foglio', + addSheet: 'Aggiungi foglio', + sheetMenu: 'Apri menu del foglio', + borderColor: 'Colore bordo', + borderStyle: 'Stile bordo', + recentColors: 'Colori recenti', + customColor: 'Colore personalizzato', + applyColor: 'Applica colore', + back: 'Indietro', + formula: 'Formula', + collapseEditor: 'Comprimi editor', + confirm: 'Conferma', + customFormatDescription: 'Genera formati numerici personalizzati basati sui formati esistenti.', + }, sheetConfig: { sheetTabs: 'Schede dei fogli', diff --git a/packages/sheets-ui/src/locale/ja-JP.ts b/packages/sheets-ui/src/locale/ja-JP.ts index 336469d597..82e319bae1 100644 --- a/packages/sheets-ui/src/locale/ja-JP.ts +++ b/packages/sheets-ui/src/locale/ja-JP.ts @@ -115,6 +115,25 @@ const locale: typeof enUS = { rotationUp: '上へ回転', rotationDown: '下へ回転', }, + mobile: { + insert: '挿入', + operation: '操作', + style: 'スタイル', + openTools: 'シートツールを開く', + closeTools: 'シートツールを閉じる', + addSheet: 'シートを追加', + sheetMenu: 'シートメニューを開く', + borderColor: '罫線の色', + borderStyle: '罫線のスタイル', + recentColors: '最近使用した色', + customColor: 'カスタム色', + applyColor: '色を適用', + back: '戻る', + formula: '数式', + collapseEditor: 'エディターを折りたたむ', + confirm: '確認', + customFormatDescription: '既存の表示形式を基にユーザー定義の数値表示形式を作成します。', + }, sheetConfig: { sheetTabs: 'シートタブ', diff --git a/packages/sheets-ui/src/locale/ko-KR.ts b/packages/sheets-ui/src/locale/ko-KR.ts index 9ff6b56c0d..280609a4a8 100644 --- a/packages/sheets-ui/src/locale/ko-KR.ts +++ b/packages/sheets-ui/src/locale/ko-KR.ts @@ -115,6 +115,25 @@ const locale: typeof enUS = { rotationUp: '위로 회전', rotationDown: '아래로 회전', }, + mobile: { + insert: '삽입', + operation: '작업', + style: '스타일', + openTools: '시트 도구 열기', + closeTools: '시트 도구 닫기', + addSheet: '시트 추가', + sheetMenu: '시트 메뉴 열기', + borderColor: '테두리 색', + borderStyle: '테두리 스타일', + recentColors: '최근 사용한 색', + customColor: '사용자 지정 색', + applyColor: '색 적용', + back: '뒤로', + formula: '수식', + collapseEditor: '편집기 접기', + confirm: '확인', + customFormatDescription: '기존 서식을 기반으로 사용자 지정 숫자 서식을 만듭니다.', + }, sheetConfig: { sheetTabs: '시트 탭', diff --git a/packages/sheets-ui/src/locale/pl-PL.ts b/packages/sheets-ui/src/locale/pl-PL.ts index 1b45c77aef..76f323c9ab 100644 --- a/packages/sheets-ui/src/locale/pl-PL.ts +++ b/packages/sheets-ui/src/locale/pl-PL.ts @@ -115,6 +115,25 @@ const locale: typeof enUS = { rotationUp: 'Obróć w górę', rotationDown: 'Obróć w dół', }, + mobile: { + insert: 'Wstaw', + operation: 'Operacje', + style: 'Styl', + openTools: 'Otwórz narzędzia arkusza', + closeTools: 'Zamknij narzędzia arkusza', + addSheet: 'Dodaj arkusz', + sheetMenu: 'Otwórz menu arkusza', + borderColor: 'Kolor obramowania', + borderStyle: 'Styl obramowania', + recentColors: 'Ostatnie kolory', + customColor: 'Kolor niestandardowy', + applyColor: 'Zastosuj kolor', + back: 'Wstecz', + formula: 'Formuła', + collapseEditor: 'Zwiń edytor', + confirm: 'Potwierdź', + customFormatDescription: 'Generuj niestandardowe formaty liczb na podstawie istniejących formatów.', + }, sheetConfig: { sheetTabs: 'Karty arkuszy', diff --git a/packages/sheets-ui/src/locale/pt-BR.ts b/packages/sheets-ui/src/locale/pt-BR.ts index e08c1c6853..85ef0ae47a 100644 --- a/packages/sheets-ui/src/locale/pt-BR.ts +++ b/packages/sheets-ui/src/locale/pt-BR.ts @@ -115,6 +115,25 @@ const locale: typeof enUS = { rotationUp: 'Rotacionar para cima', rotationDown: 'Rotacionar para baixo', }, + mobile: { + insert: 'Inserir', + operation: 'Ações', + style: 'Estilo', + openTools: 'Abrir ferramentas da planilha', + closeTools: 'Fechar ferramentas da planilha', + addSheet: 'Adicionar planilha', + sheetMenu: 'Abrir menu da planilha', + borderColor: 'Cor da borda', + borderStyle: 'Estilo da borda', + recentColors: 'Cores recentes', + customColor: 'Cor personalizada', + applyColor: 'Aplicar cor', + back: 'Voltar', + formula: 'Fórmula', + collapseEditor: 'Recolher editor', + confirm: 'Confirmar', + customFormatDescription: 'Gere formatos de número personalizados com base em formatos existentes.', + }, sheetConfig: { sheetTabs: 'Abas de planilhas', diff --git a/packages/sheets-ui/src/locale/ru-RU.ts b/packages/sheets-ui/src/locale/ru-RU.ts index 245cf8f454..0dc5d198fc 100644 --- a/packages/sheets-ui/src/locale/ru-RU.ts +++ b/packages/sheets-ui/src/locale/ru-RU.ts @@ -115,6 +115,25 @@ const locale: typeof enUS = { rotationUp: 'Поворот вверх', rotationDown: 'Поворот вниз', }, + mobile: { + insert: 'Вставка', + operation: 'Действия', + style: 'Стиль', + openTools: 'Открыть инструменты листа', + closeTools: 'Закрыть инструменты листа', + addSheet: 'Добавить лист', + sheetMenu: 'Открыть меню листа', + borderColor: 'Цвет границы', + borderStyle: 'Стиль границы', + recentColors: 'Недавние цвета', + customColor: 'Пользовательский цвет', + applyColor: 'Применить цвет', + back: 'Назад', + formula: 'Формула', + collapseEditor: 'Свернуть редактор', + confirm: 'Подтвердить', + customFormatDescription: 'Генерация пользовательских числовых форматов на основе существующих форматов.', + }, sheetConfig: { sheetTabs: 'Вкладки листов', diff --git a/packages/sheets-ui/src/locale/sk-SK.ts b/packages/sheets-ui/src/locale/sk-SK.ts index 38484ada9f..370a316029 100644 --- a/packages/sheets-ui/src/locale/sk-SK.ts +++ b/packages/sheets-ui/src/locale/sk-SK.ts @@ -115,6 +115,25 @@ const locale: typeof enUS = { rotationUp: 'Otočiť nahor', rotationDown: 'Otočiť nadol', }, + mobile: { + insert: 'Vložiť', + operation: 'Operácie', + style: 'Štýl', + openTools: 'Otvoriť nástroje hárka', + closeTools: 'Zavrieť nástroje hárka', + addSheet: 'Pridať hárok', + sheetMenu: 'Otvoriť ponuku hárka', + borderColor: 'Farba orámovania', + borderStyle: 'Štýl orámovania', + recentColors: 'Nedávne farby', + customColor: 'Vlastná farba', + applyColor: 'Použiť farbu', + back: 'Späť', + formula: 'Vzorec', + collapseEditor: 'Zbaliť editor', + confirm: 'Potvrdiť', + customFormatDescription: 'Vytvorte vlastné formáty čísiel na základe existujúcich formátov.', + }, sheetConfig: { sheetTabs: 'Karty hárkov', diff --git a/packages/sheets-ui/src/locale/vi-VN.ts b/packages/sheets-ui/src/locale/vi-VN.ts index 2f692a1c9a..15a5539379 100644 --- a/packages/sheets-ui/src/locale/vi-VN.ts +++ b/packages/sheets-ui/src/locale/vi-VN.ts @@ -115,6 +115,25 @@ const locale: typeof enUS = { rotationUp: 'Xoay lên', rotationDown: 'Xoay xuống', }, + mobile: { + insert: 'Chèn', + operation: 'Thao tác', + style: 'Kiểu', + openTools: 'Mở công cụ trang tính', + closeTools: 'Đóng công cụ trang tính', + addSheet: 'Thêm trang tính', + sheetMenu: 'Mở menu trang tính', + borderColor: 'Màu viền', + borderStyle: 'Kiểu viền', + recentColors: 'Màu gần đây', + customColor: 'Màu tùy chỉnh', + applyColor: 'Áp dụng màu', + back: 'Quay lại', + formula: 'Công thức', + collapseEditor: 'Thu gọn trình soạn thảo', + confirm: 'Xác nhận', + customFormatDescription: 'Tạo định dạng số tùy chỉnh dựa trên các định dạng hiện có.', + }, sheetConfig: { sheetTabs: 'Thẻ trang tính', diff --git a/packages/sheets-ui/src/locale/zh-CN.ts b/packages/sheets-ui/src/locale/zh-CN.ts index 120e2a68c3..21f808685f 100644 --- a/packages/sheets-ui/src/locale/zh-CN.ts +++ b/packages/sheets-ui/src/locale/zh-CN.ts @@ -115,6 +115,25 @@ const locale: typeof enUS = { rotationUp: '向上90°', rotationDown: '向下90°', }, + mobile: { + insert: '插入', + operation: '操作', + style: '样式', + openTools: '打开表格工具', + closeTools: '关闭表格工具', + addSheet: '新建工作表', + sheetMenu: '打开工作表菜单', + borderColor: '边框颜色', + borderStyle: '边框样式', + recentColors: '最近使用的颜色', + customColor: '自定义颜色', + applyColor: '应用颜色', + back: '返回', + formula: '公式', + collapseEditor: '收起编辑器', + confirm: '确认', + customFormatDescription: '根据现有格式生成自定义数字格式。', + }, sheetConfig: { sheetTabs: '工作表标签页', diff --git a/packages/sheets-ui/src/locale/zh-HK.ts b/packages/sheets-ui/src/locale/zh-HK.ts index ea9783844e..e9f336dd13 100644 --- a/packages/sheets-ui/src/locale/zh-HK.ts +++ b/packages/sheets-ui/src/locale/zh-HK.ts @@ -115,6 +115,25 @@ const locale: typeof enUS = { rotationUp: '向上90°', rotationDown: '向下90°', }, + mobile: { + insert: '插入', + operation: '操作', + style: '樣式', + openTools: '開啟試算表工具', + closeTools: '關閉試算表工具', + addSheet: '新增工作表', + sheetMenu: '開啟工作表選單', + borderColor: '邊框顏色', + borderStyle: '邊框樣式', + recentColors: '最近使用的顏色', + customColor: '自訂顏色', + applyColor: '套用顏色', + back: '返回', + formula: '公式', + collapseEditor: '收起編輯器', + confirm: '確認', + customFormatDescription: '基於現有格式產生自訂數字格式。', + }, sheetConfig: { sheetTabs: '工作表分頁', diff --git a/packages/sheets-ui/src/locale/zh-TW.ts b/packages/sheets-ui/src/locale/zh-TW.ts index ea9783844e..128cea0de2 100644 --- a/packages/sheets-ui/src/locale/zh-TW.ts +++ b/packages/sheets-ui/src/locale/zh-TW.ts @@ -115,6 +115,25 @@ const locale: typeof enUS = { rotationUp: '向上90°', rotationDown: '向下90°', }, + mobile: { + insert: '插入', + operation: '操作', + style: '樣式', + openTools: '開啟試算表工具', + closeTools: '關閉試算表工具', + addSheet: '新增工作表', + sheetMenu: '開啟工作表選單', + borderColor: '框線色彩', + borderStyle: '框線樣式', + recentColors: '最近使用的色彩', + customColor: '自訂色彩', + applyColor: '套用色彩', + back: '返回', + formula: '公式', + collapseEditor: '收合編輯器', + confirm: '確認', + customFormatDescription: '基於現有格式產生自訂數字格式。', + }, sheetConfig: { sheetTabs: '工作表分頁', diff --git a/packages/sheets-ui/src/services/__tests__/editor-bridge.service.spec.ts b/packages/sheets-ui/src/services/__tests__/editor-bridge.service.spec.ts index c3550bf10c..133d6d4385 100644 --- a/packages/sheets-ui/src/services/__tests__/editor-bridge.service.spec.ts +++ b/packages/sheets-ui/src/services/__tests__/editor-bridge.service.spec.ts @@ -25,11 +25,12 @@ import { import { IEditorService } from '@univerjs/docs-ui'; import { DeviceInputEventType, IRenderManagerService } from '@univerjs/engine-render'; import { SheetInterceptorService, SheetSkeletonService } from '@univerjs/sheets'; +import { DISABLE_AUTO_FOCUS_KEY } from '@univerjs/ui'; import { Subject } from 'rxjs'; import { describe, expect, it, vi } from 'vitest'; import { EditorBridgeService, IEditorBridgeService } from '../editor-bridge.service'; -function createService(options?: { hasFocusEditor?: boolean }) { +function createService(options?: { disableAutoFocus?: boolean; hasFocusEditor?: boolean }) { const unitDisposed$ = new Subject(); const workbook = { getUnitId: () => 'unit-1', @@ -57,6 +58,7 @@ function createService(options?: { hasFocusEditor?: boolean }) { getUnit: vi.fn((unitId: string, type?: UniverInstanceType) => unitId === 'unit-1' ? mocks.univerInstanceService.getCurrentUnitOfType(type as never) : null), + setCurrentUnitForType: vi.fn(), }, editorService: { getFocusEditor: vi.fn(() => (options?.hasFocusEditor ? { id: 'existing' } : null)), @@ -64,6 +66,7 @@ function createService(options?: { hasFocusEditor?: boolean }) { }, contextService: { setContextValue: vi.fn(), + getContextValue: vi.fn((key: string) => key === DISABLE_AUTO_FOCUS_KEY && Boolean(options?.disableAutoFocus)), }, }; @@ -87,6 +90,7 @@ function createService(options?: { hasFocusEditor?: boolean }) { getTypeOfUnitDisposed$ = mocks.univerInstanceService.getTypeOfUnitDisposed$; getCurrentUnitOfType = mocks.univerInstanceService.getCurrentUnitOfType; getUnit = mocks.univerInstanceService.getUnit; + setCurrentUnitForType = mocks.univerInstanceService.setCurrentUnitForType; } class TestEditorService { @@ -96,6 +100,7 @@ function createService(options?: { hasFocusEditor?: boolean }) { class TestContextService { setContextValue = mocks.contextService.setContextValue; + getContextValue = mocks.contextService.getContextValue; } const injector = new Injector(); @@ -218,6 +223,17 @@ describe('EditorBridgeService', () => { expect(service.getEditCellLayout()).toBeNull(); }); + it('selects the internal editor without focusing its DOM input when automatic focus is disabled', () => { + const { service, mocks } = createService({ disableAutoFocus: true }); + vi.spyOn(service, 'getLatestEditCellState').mockReturnValue(undefined); + + service.setEditCell(createEditCellParam()); + + expect(mocks.univerInstanceService.setCurrentUnitForType).toHaveBeenCalledWith(DOCS_NORMAL_EDITOR_UNIT_ID_KEY); + expect(mocks.editorService.focus).not.toHaveBeenCalled(); + expect(mocks.contextService.setContextValue).not.toHaveBeenCalled(); + }); + it('manages visible/dirty/force-keep states and null-latest branches', () => { const { service, mocks } = createService({ hasFocusEditor: true }); const visibleValues: any[] = []; diff --git a/packages/sheets-ui/src/services/editor-bridge.service.ts b/packages/sheets-ui/src/services/editor-bridge.service.ts index 0395eba8a3..17bce01f5d 100644 --- a/packages/sheets-ui/src/services/editor-bridge.service.ts +++ b/packages/sheets-ui/src/services/editor-bridge.service.ts @@ -47,6 +47,7 @@ import { SheetInterceptorService, SheetSkeletonService, } from '@univerjs/sheets'; +import { DISABLE_AUTO_FOCUS_KEY } from '@univerjs/ui'; import { BehaviorSubject, map, switchMap } from 'rxjs'; import { getViewportByCell } from '../common/utils'; @@ -256,12 +257,16 @@ export class EditorBridgeService extends Disposable implements IEditorBridgeServ * todo: wzhudev: In boundless mode, it is necessary to switch to the corresponding editorId based on the host's unitId. */ if (!this._editorService.getFocusEditor()) { - this._editorService.focus(DOCS_NORMAL_EDITOR_UNIT_ID_KEY); - /** - * Fix: When the sheet loads for the first time, copying and pasting triggers the editor, and the edits are ineffective. - */ - this._contextService.setContextValue(EDITOR_ACTIVATED, false); - this._contextService.setContextValue(FOCUSING_EDITOR_STANDALONE, false); + if (this._contextService.getContextValue(DISABLE_AUTO_FOCUS_KEY)) { + this._univerInstanceService.setCurrentUnitForType(DOCS_NORMAL_EDITOR_UNIT_ID_KEY); + } else { + this._editorService.focus(DOCS_NORMAL_EDITOR_UNIT_ID_KEY); + /** + * Fix: When the sheet loads for the first time, copying and pasting triggers the editor, and the edits are ineffective. + */ + this._contextService.setContextValue(EDITOR_ACTIVATED, false); + this._contextService.setContextValue(FOCUSING_EDITOR_STANDALONE, false); + } } const editCellState = this.getLatestEditCellState(); diff --git a/packages/sheets-ui/src/services/selection/__tests__/mobile-selection-render.service-behavior.spec.ts b/packages/sheets-ui/src/services/selection/__tests__/mobile-selection-render.service-behavior.spec.ts index 39d2c6e9a1..13336673c9 100644 --- a/packages/sheets-ui/src/services/selection/__tests__/mobile-selection-render.service-behavior.spec.ts +++ b/packages/sheets-ui/src/services/selection/__tests__/mobile-selection-render.service-behavior.spec.ts @@ -15,18 +15,24 @@ */ import { describe, expect, it } from 'vitest'; -import { shouldKeepCurrentSelectionOnMobileLongPress } from '../mobile-selection-render.service'; +import { shouldHandleMobileNormalSelectionPointerDown, shouldKeepCurrentSelectionOnMobileTap } from '../mobile-selection-render.service'; describe('MobileSheetsSelectionRenderService', () => { - it('keeps the current mobile selection when a long press stays inside an existing range', () => { - expect(shouldKeepCurrentSelectionOnMobileLongPress( + it('keeps the current mobile selection when a tap stays inside an existing range', () => { + expect(shouldKeepCurrentSelectionOnMobileTap( [{ startRow: 1, endRow: 3, startColumn: 1, endColumn: 3 }], { startRow: 2, endRow: 2, startColumn: 2, endColumn: 2 } )).toBe(true); - expect(shouldKeepCurrentSelectionOnMobileLongPress( + expect(shouldKeepCurrentSelectionOnMobileTap( [{ startRow: 1, endRow: 3, startColumn: 1, endColumn: 3 }], { startRow: 4, endRow: 4, startColumn: 2, endColumn: 2 } )).toBe(false); }); + + it('lets the formula reference renderer own pointer down while reference selection is enabled', () => { + expect(shouldHandleMobileNormalSelectionPointerDown(true, false)).toBe(false); + expect(shouldHandleMobileNormalSelectionPointerDown(false, true)).toBe(false); + expect(shouldHandleMobileNormalSelectionPointerDown(false, false)).toBe(true); + }); }); diff --git a/packages/sheets-ui/src/services/selection/__tests__/mobile-selection-render.service.spec.ts b/packages/sheets-ui/src/services/selection/__tests__/mobile-selection-render.service.spec.ts index 7fb9cb4022..6b4f737a9f 100644 --- a/packages/sheets-ui/src/services/selection/__tests__/mobile-selection-render.service.spec.ts +++ b/packages/sheets-ui/src/services/selection/__tests__/mobile-selection-render.service.spec.ts @@ -16,7 +16,7 @@ import { Injector, RANGE_TYPE, ThemeService } from '@univerjs/core'; import { SHEET_VIEWPORT_KEY } from '@univerjs/engine-render'; -import { SetSelectionsOperation } from '@univerjs/sheets'; +import { SelectionMoveType, SELECTIONS_ENABLED, SetSelectionsOperation, SheetsSelectionsService } from '@univerjs/sheets'; import { IShortcutService } from '@univerjs/ui'; import { BehaviorSubject } from 'rxjs'; import { beforeAll, describe, expect, it } from 'vitest'; @@ -29,7 +29,7 @@ import { createTestEvent, } from '../../../controllers/render-controllers/__tests__/render-test-bed'; import { SheetScrollManagerService } from '../../scroll-manager.service'; -import { MobileSheetsSelectionRenderService, shouldKeepCurrentSelectionOnMobileLongPress } from '../mobile-selection-render.service'; +import { MobileSheetsSelectionRenderService, shouldKeepCurrentSelectionOnMobileTap } from '../mobile-selection-render.service'; import { MobileSelectionControl } from '../mobile-selection-shape'; class TestThemeService { @@ -48,6 +48,11 @@ class TestSheetScrollManagerService { readonly validViewportScrollInfo$ = new BehaviorSubject<{ viewportScrollX: number; viewportScrollY: number } | null>(null); } +function installPointerUpEvent(mainComponent: ReturnType['context']['mainComponent']) { + if (!mainComponent) throw new Error('Expected the sheet render component to be available.'); + Object.assign(mainComponent, { onPointerUp$: createTestEvent() }); +} + function createMobileControl(rangeType: RANGE_TYPE) { const injector = new Injector(); injector.add([ThemeService, { useClass: TestThemeService }]); @@ -149,7 +154,7 @@ function installCellLookupForMobileSelection(skeleton: ReturnType(range: T) => T }).expandRangeByMerge = (range) => range; } -describe('shouldKeepCurrentSelectionOnMobileLongPress', () => { +describe('shouldKeepCurrentSelectionOnMobileTap', () => { beforeAll(() => { globalThis.window = { cancelAnimationFrame: () => { }, @@ -157,8 +162,77 @@ describe('shouldKeepCurrentSelectionOnMobileLongPress', () => { } as unknown as Window & typeof globalThis; }); - it('keeps the existing selection when long press is inside it', () => { - expect(shouldKeepCurrentSelectionOnMobileLongPress([ + it('restores a keyboard-moved selection when cell editing ends', () => { + const testBed = createRenderTestBed({ + dependencies: [ + [IShortcutService, { useClass: TestShortcutService }], + [SheetScrollManagerService, { useClass: TestSheetScrollManagerService }], + ], + }); + const { injector, sheet, commandService, sheetSkeletonManagerService, skeleton, context, contextService } = testBed; + installPointerUpEvent(context.mainComponent); + commandService.registerCommand(SetSelectionsOperation); + const service = injector.createInstance(MobileSheetsSelectionRenderService, context); + expect(contextService.getContextValue(SELECTIONS_ENABLED)).toBe(true); + + sheetSkeletonManagerService.emitCurrentSkeleton({ + unitId: sheet.getUnitId(), + sheetId: 'sheet1', + skeleton, + }); + injector.get(SheetsSelectionsService).setSelections(sheet.getUnitId(), 'sheet1', [{ + range: { + startRow: 2, + endRow: 2, + startColumn: 1, + endColumn: 1, + }, + primary: { + startRow: 2, + endRow: 2, + startColumn: 1, + endColumn: 1, + actualRow: 2, + actualColumn: 1, + isMerged: false, + isMergedMainCell: false, + }, + }], SelectionMoveType.MOVE_END); + + expect(service.getSelectionControls()).toHaveLength(1); + injector.get(SheetsSelectionsService).setSelections(sheet.getUnitId(), 'sheet1', [], SelectionMoveType.MOVE_END); + expect(service.getSelectionControls()).toHaveLength(0); + injector.get(SheetsSelectionsService).setSelections(sheet.getUnitId(), 'sheet1', [{ + range: { + startRow: 3, + endRow: 3, + startColumn: 1, + endColumn: 1, + }, + primary: { + startRow: 3, + endRow: 3, + startColumn: 1, + endColumn: 1, + actualRow: 3, + actualColumn: 1, + isMerged: false, + isMergedMainCell: false, + }, + }], SelectionMoveType.MOVE_END); + expect(service.getActiveRange()).toEqual({ + startRow: 3, + endRow: 3, + startColumn: 1, + endColumn: 1, + }); + + service.dispose(); + testBed.univer.dispose(); + }); + + it('keeps the existing selection when a tap is inside it', () => { + expect(shouldKeepCurrentSelectionOnMobileTap([ { startRow: 1, endRow: 3, @@ -175,8 +249,8 @@ describe('shouldKeepCurrentSelectionOnMobileLongPress', () => { })).toBe(true); }); - it('does not keep the existing selection when long press is outside it', () => { - expect(shouldKeepCurrentSelectionOnMobileLongPress([ + it('does not keep the existing selection when a tap is outside it', () => { + expect(shouldKeepCurrentSelectionOnMobileTap([ { startRow: 1, endRow: 3, @@ -194,7 +268,7 @@ describe('shouldKeepCurrentSelectionOnMobileLongPress', () => { }); it('checks all existing selections', () => { - expect(shouldKeepCurrentSelectionOnMobileLongPress([ + expect(shouldKeepCurrentSelectionOnMobileTap([ { startRow: 1, endRow: 1, @@ -270,8 +344,14 @@ describe('shouldKeepCurrentSelectionOnMobileLongPress', () => { [SheetScrollManagerService, { useClass: TestSheetScrollManagerService }], ], }); - const { injector, sheet, commandService, sheetSkeletonManagerService, skeleton, context } = testBed; - (context.mainComponent as never as { onPointerUp$: unknown }).onPointerUp$ = createTestEvent(); + const { injector, sheet, commandService, sheetSkeletonManagerService, skeleton, context, scene } = testBed; + installPointerUpEvent(context.mainComponent); + let clearSelectedObjectsCount = 0; + Object.assign(scene, { + getTransformer: () => ({ + clearSelectedObjects: () => clearSelectedObjectsCount++, + }), + }); (skeleton as never as { getCellByOffset: (x: number, y: number, scaleX: number, scaleY: number, scrollXY: { x: number; y: number }) => unknown; getColumnCount: () => number; @@ -290,15 +370,16 @@ describe('shouldKeepCurrentSelectionOnMobileLongPress', () => { (skeleton as never as { getColumnCount: () => number }).getColumnCount = () => skeleton.worksheet.getColumnCount(); (skeleton as never as { getRowCount: () => number }).getRowCount = () => skeleton.worksheet.getRowCount(); commandService.registerCommand(SetSelectionsOperation); - const service = injector.createInstance(MobileSheetsSelectionRenderService, context as never); + const service = injector.createInstance(MobileSheetsSelectionRenderService, context); sheetSkeletonManagerService.emitCurrentSkeleton({ unitId: sheet.getUnitId(), sheetId: 'sheet1', - skeleton: skeleton as never, + skeleton, }); service.createNewSelection({ offsetX: 150, offsetY: 45 } as never, 0, RANGE_TYPE.NORMAL); + expect(clearSelectedObjectsCount).toBe(1); expect(service.getSelectionControls()).toHaveLength(1); expect(service.getActiveRange()).toEqual({ startRow: 2, @@ -308,6 +389,7 @@ describe('shouldKeepCurrentSelectionOnMobileLongPress', () => { }); service.createNewSelection({ offsetX: 150, offsetY: 45 } as never, 0, RANGE_TYPE.ROW); + expect(clearSelectedObjectsCount).toBe(2); expect(service.getActiveRange()).toMatchObject({ startRow: 2, endRow: 2, @@ -327,6 +409,7 @@ describe('shouldKeepCurrentSelectionOnMobileLongPress', () => { }, }); expect(stopped).toBe(true); + expect(clearSelectedObjectsCount).toBe(3); expect(service.getActiveRange()).toMatchObject({ startRow: 0, startColumn: 0, @@ -346,16 +429,16 @@ describe('shouldKeepCurrentSelectionOnMobileLongPress', () => { ], }); const { injector, sheet, commandService, sheetSkeletonManagerService, skeleton, context, scene, contextService } = testBed; - (context.mainComponent as never as { onPointerUp$: unknown }).onPointerUp$ = createTestEvent(); + installPointerUpEvent(context.mainComponent); installCellLookupForMobileSelection(skeleton); - (scene as never as { getTransformer: () => { clearSelectedObjects: () => void } }).getTransformer = () => ({ clearSelectedObjects: () => { } }); + Object.assign(scene, { getTransformer: () => ({ clearSelectedObjects: () => { } }) }); commandService.registerCommand(SetSelectionsOperation); - const service = injector.createInstance(MobileSheetsSelectionRenderService, context as never); + const service = injector.createInstance(MobileSheetsSelectionRenderService, context); sheetSkeletonManagerService.emitCurrentSkeleton({ unitId: sheet.getUnitId(), sheetId: 'sheet1', - skeleton: skeleton as never, + skeleton, }); service.createNewSelection({ offsetX: 150, offsetY: 45 } as never, 0, RANGE_TYPE.NORMAL); @@ -389,8 +472,14 @@ describe('shouldKeepCurrentSelectionOnMobileLongPress', () => { [SheetScrollManagerService, { useClass: TestSheetScrollManagerService }], ], }); - const { injector, sheet, commandService, sheetSkeletonManagerService, skeleton, context, contextService } = testBed; - (context.mainComponent as never as { onPointerUp$: unknown }).onPointerUp$ = createTestEvent(); + const { injector, sheet, commandService, sheetSkeletonManagerService, skeleton, context, contextService, scene } = testBed; + installPointerUpEvent(context.mainComponent); + let clearSelectedObjectsCount = 0; + Object.assign(scene, { + getTransformer: () => ({ + clearSelectedObjects: () => clearSelectedObjectsCount++, + }), + }); (skeleton as never as { getCellByOffset: (x: number, y: number, scaleX: number, scaleY: number, scrollXY: { x: number; y: number }) => unknown; getColumnCount: () => number; @@ -422,12 +511,12 @@ describe('shouldKeepCurrentSelectionOnMobileLongPress', () => { (skeleton as never as { getRowCount: () => number }).getRowCount = () => skeleton.worksheet.getRowCount(); (skeleton as never as { expandRangeByMerge: (range: T) => T }).expandRangeByMerge = (range) => range; commandService.registerCommand(SetSelectionsOperation); - const service = injector.createInstance(MobileSheetsSelectionRenderService, context as never); + const service = injector.createInstance(MobileSheetsSelectionRenderService, context); sheetSkeletonManagerService.emitCurrentSkeleton({ unitId: sheet.getUnitId(), sheetId: 'sheet1', - skeleton: skeleton as never, + skeleton, }); const spreadsheet = context.mainComponent as never as { @@ -446,6 +535,7 @@ describe('shouldKeepCurrentSelectionOnMobileLongPress', () => { }, }); expect(stopped).toBe(true); + expect(clearSelectedObjectsCount).toBe(1); expect(service.getActiveRange()).toEqual({ startRow: 2, endRow: 2, @@ -453,6 +543,34 @@ describe('shouldKeepCurrentSelectionOnMobileLongPress', () => { endColumn: 1, }); + injector.get(SheetsSelectionsService).setSelections(sheet.getUnitId(), 'sheet1', [{ + range: { + startRow: 2, + endRow: 4, + startColumn: 1, + endColumn: 3, + }, + primary: null, + style: null, + }], SelectionMoveType.MOVE_END); + spreadsheet.onPointerDown$.emit({ offsetX: 154, offsetY: 49, button: 0 }, { + stopPropagation: () => { + stopped = true; + }, + }); + spreadsheet.onPointerUp$.emit({ offsetX: 154, offsetY: 49, button: 0 }, { + stopPropagation: () => { + stopped = true; + }, + }); + expect(service.getActiveRange()).toEqual({ + startRow: 2, + endRow: 4, + startColumn: 1, + endColumn: 3, + }); + expect(clearSelectedObjectsCount).toBe(2); + contextService.setContextValue('MOBILE_PINCH_ZOOMING', true); spreadsheet.onPointerDown$.emit({ offsetX: 250, offsetY: 65, button: 0 }, { stopPropagation: () => { @@ -466,10 +584,11 @@ describe('shouldKeepCurrentSelectionOnMobileLongPress', () => { }); expect(service.getActiveRange()).toEqual({ startRow: 2, - endRow: 2, + endRow: 4, startColumn: 1, - endColumn: 1, + endColumn: 3, }); + expect(clearSelectedObjectsCount).toBe(2); contextService.setContextValue('MOBILE_PINCH_ZOOMING', false); const rowHeader = context.components.get(SHEET_VIEW_KEY.ROW)!; @@ -484,6 +603,7 @@ describe('shouldKeepCurrentSelectionOnMobileLongPress', () => { startColumn: 0, endColumn: 49, }); + expect(clearSelectedObjectsCount).toBe(3); const columnHeader = context.components.get(SHEET_VIEW_KEY.COLUMN)!; (columnHeader.onPointerDown$ as unknown as { emit: (evt: unknown, state: unknown) => void }).emit({ @@ -497,6 +617,7 @@ describe('shouldKeepCurrentSelectionOnMobileLongPress', () => { startColumn: 3, endColumn: 3, }); + expect(clearSelectedObjectsCount).toBe(4); service.dispose(); testBed.univer.dispose(); diff --git a/packages/sheets-ui/src/services/selection/mobile-selection-render.service.ts b/packages/sheets-ui/src/services/selection/mobile-selection-render.service.ts index 8dfd039fe9..2c6eca60b8 100644 --- a/packages/sheets-ui/src/services/selection/mobile-selection-render.service.ts +++ b/packages/sheets-ui/src/services/selection/mobile-selection-render.service.ts @@ -40,7 +40,7 @@ import { toDisposable, } from '@univerjs/core'; import { ScrollTimer, ScrollTimerType, SHEET_VIEWPORT_KEY, Vector2 } from '@univerjs/engine-render'; -import { attachSelectionWithCoord, convertSelectionDataToRange, REF_SELECTIONS_ENABLED, SelectionMoveType, SetSelectionsOperation, SheetsSelectionsService } from '@univerjs/sheets'; +import { attachSelectionWithCoord, convertSelectionDataToRange, REF_SELECTIONS_ENABLED, SelectionMoveType, SELECTIONS_ENABLED, SetSelectionsOperation, SheetsSelectionsService } from '@univerjs/sheets'; import { IShortcutService } from '@univerjs/ui'; import { distinctUntilChanged, merge, startWith } from 'rxjs'; import { MOBILE_EXPANDING_SELECTION, MOBILE_PINCH_ZOOMING } from '../../consts/mobile-context'; @@ -60,10 +60,14 @@ enum ExpandingControl { BOTTOM = 'bottom', } -export function shouldKeepCurrentSelectionOnMobileLongPress(currentSelections: IRange[], targetRange: IRange): boolean { +export function shouldKeepCurrentSelectionOnMobileTap(currentSelections: IRange[], targetRange: IRange): boolean { return currentSelections.some((selection) => Rectangle.contains(selection, targetRange)); } +export function shouldHandleMobileNormalSelectionPointerDown(refSelectionsEnabled: boolean, pinchZooming: boolean): boolean { + return !refSelectionsEnabled && !pinchZooming; +} + export class MobileSheetsSelectionRenderService extends BaseSelectionRenderService implements IRenderModule { private readonly _workbookSelections: WorkbookSelectionModel; private _renderDisposable: Nullable = null; @@ -103,6 +107,7 @@ export class MobileSheetsSelectionRenderService extends BaseSelectionRenderServi private _init() { const sheetObject = this._getSheetObject(); + this._contextService.setContextValue(SELECTIONS_ENABLED, true); this._initEventListeners(sheetObject); this._initSelectionChangeListener(); // this._initThemeChangeListener(); @@ -198,6 +203,7 @@ export class MobileSheetsSelectionRenderService extends BaseSelectionRenderServi this.disposeWithMe(spreadsheetLeftTopPlaceholder?.onPointerDown$.subscribeEvent((_evt: IPointerEvent | IMouseEvent, state: EventState) => { if (this._normalSelectionDisabled()) return; + scene.getTransformer()?.clearSelectedObjects(); this._reset(); // remove all other selections const skeleton = this._sheetSkeletonManagerService.getCurrentParam()!.skeleton; @@ -228,8 +234,12 @@ export class MobileSheetsSelectionRenderService extends BaseSelectionRenderServi this._selectionMoveEnd$.next(this.getSelectionDataWithStyle()); }; const spreadsheetPointerDownSub = spreadsheet?.onPointerDown$.subscribeEvent((evt: IPointerEvent | IMouseEvent, state) => { - // Don't start long press timer during pinch zoom - if (this._contextService.getContextValue(MOBILE_PINCH_ZOOMING)) return; + if (!shouldHandleMobileNormalSelectionPointerDown( + this._normalSelectionDisabled(), + this._contextService.getContextValue(MOBILE_PINCH_ZOOMING) + )) { + return; + } pointerDownPos.x = evt.offsetX; pointerDownPos.y = evt.offsetY; @@ -263,7 +273,8 @@ export class MobileSheetsSelectionRenderService extends BaseSelectionRenderServi if (disabled) { this._renderDisposable?.dispose(); this._renderDisposable = null; - this._reset(); + // The mobile in-cell editor is hidden, so this control is the only visible marker + // for the cell being edited while formula reference controls are rendered separately. } else { this._renderDisposable = toDisposable( this.selectionMoveEnd$.subscribe((params) => this._updateSelections(params, SelectionMoveType.MOVE_END)) @@ -331,6 +342,18 @@ export class MobileSheetsSelectionRenderService extends BaseSelectionRenderServi // const { rangeWithCoord: cursorCellRange, primaryWithCoord: primaryCursorCellRange } = cursorCellRangeInfo; const selectCell = this._skeleton.getCellByOffset(offsetX, offsetY, scaleX, scaleY, scrollXY); if (!selectCell) return; + + scene.getTransformer()?.clearSelectedObjects(); + if ( + rangeType === RANGE_TYPE.NORMAL && + shouldKeepCurrentSelectionOnMobileTap( + this._workbookSelections.getCurrentSelections().map((selection) => selection.range), + selectCell + ) + ) { + return; + } + switch (rangeType) { case RANGE_TYPE.NORMAL: break; @@ -382,7 +405,8 @@ export class MobileSheetsSelectionRenderService extends BaseSelectionRenderServi * Not same as PC version, * new selection control for mobile do one more thing: bind event for two control points. * @param scene - * @param rangeType + * @param skeleton + * @param selection */ override newSelectionControl(scene: Scene, skeleton: SpreadsheetSkeleton, selection: ISelectionWithStyle): MobileSelectionControl { const selectionControls = this.getSelectionControls(); @@ -594,8 +618,8 @@ export class MobileSheetsSelectionRenderService extends BaseSelectionRenderServi activeSelectionControl: MobileSelectionControl, rangeType: RANGE_TYPE, scrollTimerType: ScrollTimerType = ScrollTimerType.ALL, - moveStartPosX: number, - moveStartPosY: number + _moveStartPosX: number, + _moveStartPosY: number ): void { this._scrollTimer = ScrollTimer.create(this._scene, scrollTimerType); this._scrollTimer.startScroll(viewportMain?.left ?? 0, viewportMain?.top ?? 0, viewportMain); diff --git a/packages/sheets-ui/src/views/border-panel/interface.ts b/packages/sheets-ui/src/views/border-panel/interface.ts index 6709e25d13..5d215a026b 100644 --- a/packages/sheets-ui/src/views/border-panel/interface.ts +++ b/packages/sheets-ui/src/views/border-panel/interface.ts @@ -17,7 +17,7 @@ import type { IBorderInfo } from '@univerjs/sheets'; import type { ICustomComponentProps } from '@univerjs/ui'; import type { LocaleKey } from '../../locale/types'; -import { BorderStyleTypes } from '@univerjs/core'; +import { BorderStyleTypes, BorderType } from '@univerjs/core'; import { COMPONENT_PREFIX } from '../const'; export const BORDER_PANEL_COMPONENT = `${COMPONENT_PREFIX}_BORDER_PANEL_COMPONENT`; @@ -28,80 +28,80 @@ export const BORDER_LINE_CHILDREN = [ { label: 'sheets-ui.borderLine.borderTop', icon: 'UpBorderDoubleIcon', - value: 'top', + value: BorderType.TOP, }, { label: 'sheets-ui.borderLine.borderBottom', icon: 'DownBorderDoubleIcon', - value: 'bottom', + value: BorderType.BOTTOM, }, { label: 'sheets-ui.borderLine.borderLeft', icon: 'LeftBorderDoubleIcon', - value: 'left', + value: BorderType.LEFT, }, { label: 'sheets-ui.borderLine.borderRight', icon: 'RightBorderDoubleIcon', - value: 'right', + value: BorderType.RIGHT, }, { label: 'sheets-ui.borderLine.borderNone', icon: 'NoBorderIcon', - value: 'none', + value: BorderType.NONE, }, { label: 'sheets-ui.borderLine.borderAll', icon: 'AllBorderIcon', - value: 'all', + value: BorderType.ALL, }, { label: 'sheets-ui.borderLine.borderOutside', icon: 'OuterBorderDoubleIcon', - value: 'outside', + value: BorderType.OUTSIDE, }, { label: 'sheets-ui.borderLine.borderInside', icon: 'InnerBorderDoubleIcon', - value: 'inside', + value: BorderType.INSIDE, }, { label: 'sheets-ui.borderLine.borderHorizontal', icon: 'HorizontalBorderDoubleIcon', - value: 'horizontal', + value: BorderType.HORIZONTAL, }, { label: 'sheets-ui.borderLine.borderVertical', icon: 'VerticalBorderDoubleIcon', - value: 'vertical', + value: BorderType.VERTICAL, }, { label: 'sheets-ui.borderLine.borderTlbr', icon: 'BackSlashDoubleIcon', - value: 'tlbr', + value: BorderType.TLBR, }, { label: 'sheets-ui.borderLine.borderTlbcTlmr', icon: 'LeftDoubleDiagonalDoubleIcon', - value: 'tlbc_tlmr', + value: BorderType.TLBC_TLMR, }, { label: 'sheets-ui.borderLine.borderTlbrTlbcTlmr', icon: 'LeftTridiagonalDoubleIcon', - value: 'tlbr_tlbc_tlmr', + value: BorderType.TLBR_TLBC_TLMR, }, { label: 'sheets-ui.borderLine.borderBlTr', icon: 'SlashDoubleIcon', - value: 'bltr', + value: BorderType.BLTR, }, { label: 'sheets-ui.borderLine.borderMltrBctr', icon: 'RightDoubleDiagonalDoubleIcon', - value: 'mltr_bctr', + value: BorderType.MLTR_BCTR, }, -] satisfies Array<{ label: LocaleKey; icon: string; value: string }>; +] satisfies Array<{ label: LocaleKey; icon: string; value: BorderType }>; export const BORDER_SIZE_CHILDREN = [ { diff --git a/packages/sheets-ui/src/views/editor-container/EditorContainer.tsx b/packages/sheets-ui/src/views/editor-container/EditorContainer.tsx index ab2e43198b..ecbb8591d4 100644 --- a/packages/sheets-ui/src/views/editor-container/EditorContainer.tsx +++ b/packages/sheets-ui/src/views/editor-container/EditorContainer.tsx @@ -49,6 +49,7 @@ import { SetCellEditVisibleOperation, } from '../../commands/operations/cell-edit.operation'; import { EMBEDDING_FORMULA_EDITOR_COMPONENT_KEY } from '../../common/keys'; +import { MOBILE_SHEET_FX_EDITOR } from '../../consts/mobile-context'; import { IEditorBridgeService } from '../../services/editor-bridge.service'; import { ICellEditorManagerService } from '../../services/editor/cell-editor-manager.service'; import { SheetCellEditorResizeService } from '../../services/editor/cell-editor-resize.service'; @@ -229,6 +230,12 @@ export function EditorContainer() { undefined, [contextService, DISABLE_AUTO_FOCUS_KEY] ); + const mobileFxEditor = useObservable( + () => contextService.subscribeContextValue$(MOBILE_SHEET_FX_EDITOR), + contextService.getContextValue(MOBILE_SHEET_FX_EDITOR), + undefined, + [contextService] + ); const FormulaEditor = componentManager.get(EMBEDDING_FORMULA_EDITOR_COMPONENT_KEY); const editState = useObservable(editorBridgeService.currentEditCellState$); const workbook = editState @@ -349,10 +356,10 @@ export function EditorContainer() { }, [cellEditorResizeService, injector]); useEffect(() => { - if (!disableAutoFocus) { + if (!disableAutoFocus && !contextService.getContextValue(DISABLE_AUTO_FOCUS_KEY)) { cellEditorManagerService.setFocus(true); } - }, [disableAutoFocus, state]); + }, [cellEditorManagerService, contextService, disableAutoFocus, state]); useEffect(() => { if (!visible?.visible) { @@ -522,7 +529,12 @@ export function EditorContainer() { }, [contextService, editorService, focusCoordinator, injector, runtimeFocusState, visible?.visible]); useEffect(() => { - if (visible?.visible || !focusCoordinator) { + if ( + visible?.visible || + !focusCoordinator || + disableAutoFocus || + contextService.getContextValue(DISABLE_AUTO_FOCUS_KEY) + ) { return undefined; } @@ -612,7 +624,7 @@ export function EditorContainer() { ownerDocument.removeEventListener('click', refocusHiddenEditorAfterRuntimePointer, true); portalRegistration.dispose(); }; - }, [editorService, focusCoordinator, injector, instanceService, runtimeFocusState, visible?.visible]); + }, [contextService, disableAutoFocus, editorService, focusCoordinator, injector, instanceService, runtimeFocusState, visible?.visible]); useEffect(() => { return () => { @@ -692,6 +704,8 @@ export function EditorContainer() { top: state.top, width: state.width, height: state.height, + opacity: mobileFxEditor ? 0 : undefined, + pointerEvents: mobileFxEditor ? 'none' : undefined, backgroundColor: getCellEditorHostBackgroundColor(editState, { darkMode, getColorFromTheme: themeService.getColorFromTheme.bind(themeService), diff --git a/packages/sheets-ui/src/views/editor-container/__tests__/EditorContainer.spec.tsx b/packages/sheets-ui/src/views/editor-container/__tests__/EditorContainer.spec.tsx index b39959aa6e..66e3cf634e 100644 --- a/packages/sheets-ui/src/views/editor-container/__tests__/EditorContainer.spec.tsx +++ b/packages/sheets-ui/src/views/editor-container/__tests__/EditorContainer.spec.tsx @@ -136,7 +136,7 @@ class TestEditorBridgeService { dispose(): void {} } -function createTestBed(options: { docSelectionIsFocusing?: boolean; focusedUnitId?: string | null; sheetUnitIds?: string[] } = {}) { +function createTestBed(options: { disableAutoFocus?: boolean; docSelectionIsFocusing?: boolean; focusedUnitId?: string | null; sheetUnitIds?: string[] } = {}) { const injector = new Injector(); const componentManager = new ComponentManager(); const focusCoordinator = new EmbedRuntimeFocusCoordinator(); @@ -164,6 +164,7 @@ function createTestBed(options: { docSelectionIsFocusing?: boolean; focusedUnitI isFocusing: options.docSelectionIsFocusing ?? true, focus: vi.fn(), }; + const cellEditorManagerService = new TestCellEditorManagerService(); latestFormulaEditorProps = undefined; componentManager.register(EMBEDDING_FORMULA_EDITOR_COMPONENT_KEY, (props: { @@ -176,7 +177,7 @@ function createTestBed(options: { docSelectionIsFocusing?: boolean; focusedUnitI injector.add([Injector, injector]); injector.add([ComponentManager, componentManager]); injector.add([IEditorBridgeService, { useValue: editorBridgeService as never }]); - injector.add([ICellEditorManagerService, { useClass: TestCellEditorManagerService as never }]); + injector.add([ICellEditorManagerService, { useValue: cellEditorManagerService }]); injector.add([IEditorService, { useValue: { getEditor: () => ({ @@ -205,9 +206,9 @@ function createTestBed(options: { docSelectionIsFocusing?: boolean; focusedUnitI }]); injector.add([IContextService, { useValue: { - subscribeContextValue$: () => of(false), + subscribeContextValue$: () => of(options.disableAutoFocus ?? false), setContextValue: vi.fn(), - getContextValue: vi.fn(), + getContextValue: vi.fn(() => options.disableAutoFocus ?? false), } as never, }]); injector.add([ThemeService, { @@ -232,7 +233,7 @@ function createTestBed(options: { docSelectionIsFocusing?: boolean; focusedUnitI injector.add([ISheetEmbedRuntimeFocusCoordinator, { useValue: focusCoordinator }]); injector.add([ISheetEmbedInteractionBoundaryService, { useValue: interactionBoundaryService }]); - return { injector, editorBridgeService, focusCoordinator, interactionBoundaryService, docSelectionRenderService, cellEditorResizeService, validViewportScrollInfo$, activeSheet$, otherSheet }; + return { injector, editorBridgeService, focusCoordinator, interactionBoundaryService, docSelectionRenderService, cellEditorManagerService, cellEditorResizeService, validViewportScrollInfo$, activeSheet$, otherSheet }; } function renderEditorContainer(root: Root, injector: Injector): void { @@ -461,6 +462,65 @@ describe('EditorContainer embed focus lease', () => { hostCanvas.remove(); }); + it('does not focus the hidden sheet editor when automatic focus is disabled', async () => { + const { injector, editorBridgeService, focusCoordinator } = createTestBed({ + disableAutoFocus: true, + focusedUnitId: 'host-doc', + sheetUnitIds: ['scoped-child-sheet'], + }); + const selectionContainer = document.createElement('div'); + selectionContainer.id = `univer-doc-selection-container-${DOCS_NORMAL_EDITOR_UNIT_ID_KEY}`; + const internalEditor = document.createElement('div'); + internalEditor.id = `__editor_${DOCS_NORMAL_EDITOR_UNIT_ID_KEY}`; + internalEditor.tabIndex = -1; + selectionContainer.appendChild(internalEditor); + document.body.appendChild(selectionContainer); + const hostCanvas = document.createElement('canvas'); + hostCanvas.tabIndex = -1; + document.body.appendChild(hostCanvas); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + + editorBridgeService.changeVisible({ + visible: false, + eventType: DeviceInputEventType.PointerUp, + unitId: 'scoped-child-sheet', + }); + hostCanvas.focus(); + + await act(async () => { + renderEditorContainer(root!, injector); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + const runtimeScope = focusCoordinator.registerRuntimeScope({ + embedId: 'embed-1', + hostUnitId: 'host-doc', + childUnitId: 'scoped-child-sheet', + childType: UniverInstanceType.UNIVER_SHEET, + }); + const activeSession = focusCoordinator.acquireLease({ + embedId: 'embed-1', + role: 'child-session', + owner: 'doc-block-stage2-runtime', + hostUnitId: 'host-doc', + childUnitId: 'scoped-child-sheet', + childType: UniverInstanceType.UNIVER_SHEET, + }); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(document.activeElement).toBe(hostCanvas); + + activeSession.dispose(); + runtimeScope.dispose(); + selectionContainer.remove(); + hostCanvas.remove(); + }); + it('does not focus the hidden sheet editor for an active base doc-block session', async () => { const { injector, editorBridgeService, focusCoordinator } = createTestBed({ focusedUnitId: 'host-doc', @@ -715,6 +775,20 @@ describe('EditorContainer embed focus lease', () => { expect(editorRoot.getAttribute(EMBED_RUNTIME_FOCUS_ROLE_ATTRIBUTE)).toBeNull(); }); + it('does not request editor focus while automatic focus is disabled', async () => { + const { cellEditorManagerService, injector } = createTestBed({ disableAutoFocus: true }); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + renderEditorContainer(root!, injector); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(cellEditorManagerService.setFocus).not.toHaveBeenCalled(); + }); + it('does not keep refocusing the embedded sheet cell editor on delayed timers after it becomes visible', async () => { const { injector } = createTestBed({ docSelectionIsFocusing: false }); const setTimeoutSpy = vi.spyOn(window, 'setTimeout'); diff --git a/packages/sheets-ui/src/views/formula-bar/FormulaBar.tsx b/packages/sheets-ui/src/views/formula-bar/FormulaBar.tsx index 24bae1d268..63dce80cf1 100644 --- a/packages/sheets-ui/src/views/formula-bar/FormulaBar.tsx +++ b/packages/sheets-ui/src/views/formula-bar/FormulaBar.tsx @@ -15,6 +15,7 @@ */ import type { IUniverSheetsUIConfig } from '../../config/config'; +import type { LocaleKey } from '../../locale/types'; import type { IEditorBridgeServiceVisibleParam } from '../../services/editor-bridge.service'; import { DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY, @@ -22,6 +23,7 @@ import { ICommandService, IContextService, IPermissionService, + LocaleService, } from '@univerjs/core'; import { borderBottomClassName, borderRightClassName, clsx } from '@univerjs/design'; import { IEditorService } from '@univerjs/docs-ui'; @@ -52,12 +54,17 @@ 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'; import { SHEETS_UI_PLUGIN_CONFIG_KEY } from '../../config/config'; +import { + MOBILE_FORMULA_BAR_SUBMIT_COMMAND_ID, + MOBILE_FORMULA_OPERATORS_VISIBLE, +} from '../../consts/mobile-context'; import { SheetsUIPart } from '../../consts/ui-name'; import { IEditorBridgeService } from '../../services/editor-bridge.service'; import { IFormulaEditorManagerService } from '../../services/editor/formula-editor-manager.service'; import { DefinedName } from '../defined-name/DefinedName'; import { useKeyEventConfig } from '../editor-container/hooks'; import { useActiveWorkbook } from '../hook'; +import { MobileFormulaBarActions, MobileFormulaBarOverlays } from '../mobile/formula-bar/MobileFormulaBarControls'; enum ArrowDirection { Down, @@ -67,11 +74,15 @@ enum ArrowDirection { interface IProps { className?: string; disableDefinedName?: boolean; + mobile?: boolean; + expanded?: boolean; + onExpandedChange?: (expanded: boolean) => void; } export function FormulaBar(props: IProps) { - const { className, disableDefinedName } = props; + const { className, disableDefinedName, expanded = false, mobile = false, onExpandedChange } = props; const editorBridgeService = useDependency(IEditorBridgeService); + const localeService = useDependency(LocaleService); const iconActivated = useObservable( () => editorBridgeService.visible$.pipe(map((visibleInfo) => visibleInfo.visible)), false, @@ -79,6 +90,12 @@ export function FormulaBar(props: IProps) { [editorBridgeService] ); const [arrowDirection, setArrowDirection] = useState(ArrowDirection.Down); + const [mobileFxRequest, setMobileFxRequest] = useState(0); + const [mobileFunctionPanelRequest, setMobileFunctionPanelRequest] = useState(0); + const [mobileFormulaActive, setMobileFormulaActive] = useState(false); + const [mobileOperatorRequest, setMobileOperatorRequest] = useState({ id: 0, value: '' }); + const isExpanded = mobile ? expanded : arrowDirection === ArrowDirection.Up; + const mobileFormulaOperatorsVisible = mobile && mobileFormulaActive && !isExpanded; const formulaEditorManagerService = useDependency(IFormulaEditorManagerService); const worksheetProtectionRuleModel = useDependency(WorksheetProtectionRuleModel); const rangeProtectionRuleModel = useDependency(RangeProtectionRuleModel); @@ -105,6 +122,16 @@ export function FormulaBar(props: IProps) { useMemo(() => contextService.subscribeContextValue$(FOCUSING_FX_BAR_EDITOR), [contextService]), contextService.getContextValue(FOCUSING_FX_BAR_EDITOR) ); + useEffect(() => { + if (mobile) { + contextService.setContextValue(MOBILE_FORMULA_OPERATORS_VISIBLE, mobileFormulaOperatorsVisible); + } + }, [contextService, mobile, mobileFormulaOperatorsVisible]); + useEffect(() => () => { + if (mobile) { + contextService.setContextValue(MOBILE_FORMULA_OPERATORS_VISIBLE, false); + } + }, [contextService, mobile]); const workbookEditablePermission = useObservable(useMemo(() => { if (!workbook) { return undefined; @@ -204,6 +231,12 @@ export function FormulaBar(props: IProps) { }, [formulaEditorManagerService]); function handleArrowClick() { + if (mobile) { + onExpandedChange?.(!isExpanded); + setTimeout(() => formulaEditorManagerService.handleFoldBtnClick(isExpanded), 150); + return; + } + setArrowDirection(arrowDirection === ArrowDirection.Down ? ArrowDirection.Up : ArrowDirection.Down); const ANIMATION_DURATION = 150; @@ -223,10 +256,19 @@ export function FormulaBar(props: IProps) { unitId: editState!.unitId, }); } + onExpandedChange?.(false); } // Handle click the "√" button. - function handleConfirmBtnClick() { + async function handleConfirmBtnClick() { + if (mobile) { + const submitted = await commandService.executeCommand(MOBILE_FORMULA_BAR_SUBMIT_COMMAND_ID); + if (submitted) { + onExpandedChange?.(false); + } + return; + } + const visibleState = editorBridgeService.isVisible(); if (visibleState.visible) { commandService.executeCommand(SetCellEditVisibleOperation.id, { @@ -235,10 +277,19 @@ export function FormulaBar(props: IProps) { unitId: editState!.unitId, }); } + onExpandedChange?.(false); } function handlerFxBtnClick() { - formulaEditorManagerService.handleFxBtnClick(true); + if (mobile) { + const formulaEditor = editorService.getEditor(DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY); + const formulaText = formulaEditor?.getDocumentData().body?.dataStream?.replace(/\r?\n$/, '') ?? ''; + if (formulaText.startsWith('=')) { + setMobileFxRequest((value) => value + 1); + setMobileFunctionPanelRequest((value) => value + 1); + } + } + formulaEditorManagerService.handleFxBtnClick(mobile); } // TODO Is there a need to disable an editor here? @@ -294,88 +345,140 @@ export function FormulaBar(props: IProps) { const cellImage = isCellImage(editState?.documentLayoutObject.documentModel?.getSnapshot()); const hideEditor = cellImage || viewDisable; + const cellStyle = editState + ? workbook?.getSheetBySheetId(editState.sheetId)?.getCellStyle(editState.row, editState.column) + : undefined; + const editorBackground = mobile ? cellStyle?.bg?.rgb ?? undefined : undefined; return (
    + {mobile && ( + setMobileOperatorRequest(({ id }) => ({ id: id + 1, value }))} + /> + )}
    -
    -
    - {/* TODO: use buttons to replace these re-implementation of buttons. */} - ('sheets-ui.button.cancel')} + confirmLabel={localeService.t('sheets-ui.button.confirm')} + formulaLabel={localeService.t('sheets-ui.mobile.formula')} + collapseLabel={localeService.t('sheets-ui.mobile.collapseEditor')} + onCancel={handleCloseBtnClick} + onConfirm={handleConfirmBtnClick} + onFormula={handlerFxBtnClick} + onCollapse={handleArrowClick} + /> + ) + : ( +
    - - - - - - - - -
    -
    +
    + {/* TODO: use buttons to replace these re-implementation of buttons. */} + + + + + + + + + +
    +
    + )} -
    +
    {FormulaEditor && ( )} {/* Cover the hidden editor instead of re-instantiating the formula editor. */} @@ -410,20 +518,27 @@ export function FormulaBar(props: IProps) { /> )}
    -
    - -
    + onClick={handleArrowClick} + > + +
    + )}
    diff --git a/packages/sheets-ui/src/views/formula-bar/__tests__/FormulaBar.spec.tsx b/packages/sheets-ui/src/views/formula-bar/__tests__/FormulaBar.spec.tsx index 7aad86785f..06caf0ea2b 100644 --- a/packages/sheets-ui/src/views/formula-bar/__tests__/FormulaBar.spec.tsx +++ b/packages/sheets-ui/src/views/formula-bar/__tests__/FormulaBar.spec.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import type { IWorkbookData, Nullable, Workbook } from '@univerjs/core'; +import type { IDocumentData, IStyleData, IWorkbookData, Nullable, Workbook } from '@univerjs/core'; import type { ReactElement } from 'react'; import type { Root } from 'react-dom/client'; import type { @@ -24,10 +24,13 @@ import type { IEditorBridgeServiceVisibleParam, } from '../../../services/editor-bridge.service'; import { + CommandType, + DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY, DOCS_NORMAL_EDITOR_UNIT_ID_KEY, ICommandService, IConfigService, IUniverInstanceService, + LocaleService, LocaleType, Univer, UniverInstanceType, @@ -53,13 +56,16 @@ import { import { ComponentManager, ILayoutService, IUIPartsService, KeyCode, RediContext, UIPartsService } from '@univerjs/ui'; import { act } from 'react'; import { createRoot } from 'react-dom/client'; -import { BehaviorSubject } from 'rxjs'; -import { afterEach, describe, expect, it } from 'vitest'; +import { BehaviorSubject, EMPTY } from 'rxjs'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { SetCellEditVisibleOperation } from '../../../commands/operations/cell-edit.operation'; import { EMBEDDING_FORMULA_EDITOR_COMPONENT_KEY } from '../../../common/keys'; import { SHEETS_UI_PLUGIN_CONFIG_KEY } from '../../../config/config'; +import { MOBILE_FORMULA_BAR_SUBMIT_COMMAND_ID } from '../../../consts/mobile-context'; +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 { FormulaBar } from '../FormulaBar'; (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; @@ -103,7 +109,7 @@ class TestEditorBridgeService implements IEditorBridgeService { this._currentEditCellState$.next(state); } - refreshEditCellState(): void {} + readonly refreshEditCellState = vi.fn(); refreshEditCellPosition(): void {} setEditCell(): void {} getEditCellState(): Readonly> { @@ -164,10 +170,30 @@ class TestEditorBridgeService implements IEditorBridgeService { } } +const testFormulaEditor = { + input$: EMPTY, + selectionChange$: EMPTY, + getDocumentData: (): IDocumentData => ({ + id: DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY, + body: { dataStream: 'Existing\r\n' }, + documentStyle: {}, + }), + getSelectionRanges: () => [], + setSelectionRanges: vi.fn(), +}; + class TestEditorService { readonly focusedEditorIds: string[] = []; readonly blurHistory: boolean[] = []; + getEditor(editorId: string) { + return editorId === DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY ? testFormulaEditor : null; + } + + getFocusId(): null { + return null; + } + focus(editorId: string): void { this.focusedEditorIds.push(editorId); } @@ -181,11 +207,14 @@ class TestLayoutService { focus(): void {} } -function TestFormulaEditor(props: { editorId: string; unitId?: string }) { +function TestFormulaEditor(props: { + editorId: string; + unitId?: string; +}) { return
    ; } -function createWorkbookData(): IWorkbookData { +function createWorkbookData(cellStyle?: IStyleData): IWorkbookData { return { id: UNIT_ID, appVersion: '3.0.0-alpha', @@ -197,7 +226,7 @@ function createWorkbookData(): IWorkbookData { [SHEET_ID]: { id: SHEET_ID, name: 'Sheet1', - cellData: {}, + cellData: cellStyle ? { 0: { 0: { s: cellStyle } } } : {}, }, }, }; @@ -220,13 +249,16 @@ function createCellEditState(): ICellEditorState { }; } -function createFormulaBarTestBed() { +function createFormulaBarTestBed(cellStyle?: IStyleData) { const originalResizeObserver = globalThis.ResizeObserver; globalThis.ResizeObserver = TestResizeObserver as typeof ResizeObserver; const univer = new Univer(); const injector = univer.__getInjector(); + injector.get(LocaleService).load({ [LocaleType.EN_US]: enUS }); + injector.get(LocaleService).setLocale(LocaleType.EN_US); + injector.add([IDefinedNamesService, { useClass: DefinedNamesService }]); injector.add([ISuperTableService, { useClass: SuperTableService }]); injector.add([IFunctionService, { useClass: FunctionService }]); @@ -242,7 +274,10 @@ function createFormulaBarTestBed() { injector.add([IEditorBridgeService, { useClass: TestEditorBridgeService as never }]); injector.add([ILayoutService, { useClass: TestLayoutService as never }]); injector.add([IEditorService, { useClass: TestEditorService as never }]); - const workbook = univer.createUnit(UniverInstanceType.UNIVER_SHEET, createWorkbookData()); + const workbook = univer.createUnit( + UniverInstanceType.UNIVER_SHEET, + createWorkbookData(cellStyle) + ); injector.get(IUniverInstanceService).focusUnit(UNIT_ID); injector.get(IConfigService).setConfig(SHEETS_UI_PLUGIN_CONFIG_KEY, {}); @@ -251,6 +286,12 @@ function createFormulaBarTestBed() { const commandService = injector.get(ICommandService); commandService.registerCommand(SetCellEditVisibleOperation); + const mobileSubmit = vi.fn(() => true); + commandService.registerCommand({ + id: MOBILE_FORMULA_BAR_SUBMIT_COMMAND_ID, + type: CommandType.COMMAND, + handler: mobileSubmit, + }); const editorBridgeService = injector.get(IEditorBridgeService) as TestEditorBridgeService; editorBridgeService.setCurrentEditCell(createCellEditState()); @@ -265,6 +306,7 @@ function createFormulaBarTestBed() { injector, workbook, editorBridgeService, + mobileSubmit, restoreResizeObserver: () => { globalThis.ResizeObserver = originalResizeObserver; }, @@ -293,7 +335,9 @@ function getActionElement(container: HTMLElement, index: number): HTMLElement { throw new TypeError('Formula bar not rendered'); } - const elements = formulaBar.querySelectorAll('span'); + const elements = formulaBar.querySelectorAll( + '[data-u-comp="formula-bar-actions"] > button, [data-u-comp="formula-bar-actions"] > span' + ); const element = elements.item(index); if (!(element instanceof HTMLElement)) { throw new TypeError(`Formula bar action ${index} not rendered`); @@ -324,6 +368,27 @@ describe('FormulaBar', () => { root = undefined; container = undefined; currentBed = undefined; + vi.unstubAllGlobals(); + }); + + it('replays the selected cell after the lazy mobile formula editor mounts', async () => { + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + callback(0); + return 1; + }); + vi.stubGlobal('cancelAnimationFrame', vi.fn()); + currentBed = createFormulaBarTestBed(); + const rendered = renderWithDependencies(, currentBed.injector); + root = rendered.root; + container = rendered.container; + + await act(async () => Promise.resolve()); + + expect(currentBed.editorBridgeService.refreshEditCellState).toHaveBeenCalledOnce(); + expect(testFormulaEditor.setSelectionRanges).toHaveBeenCalledWith([{ + startOffset: 8, + endOffset: 8, + }], false); }); it('keeps its toolbar layout LTR when the sheet host is RTL', () => { @@ -380,4 +445,86 @@ describe('FormulaBar', () => { unitId: UNIT_ID, }); }); + + it('commits and moves down from the compact mobile formula bar', async () => { + currentBed = createFormulaBarTestBed(); + const rendered = renderWithDependencies(, currentBed.injector); + root = rendered.root; + container = rendered.container; + + const editorHost = rendered.container.querySelector('[data-editor-id]')?.parentElement; + expect(editorHost?.classList).toContain('univer-my-2'); + + await clickElement(getActionElement(rendered.container, 1)); + + expect(currentBed.mobileSubmit).toHaveBeenCalledOnce(); + expect(currentBed.editorBridgeService.visibleHistory.at(-1)?.visible).toBe(true); + }); + + it('inherits the selected cell background in the mobile editor', () => { + currentBed = createFormulaBarTestBed({ + bg: { rgb: '#000000' }, + cl: { rgb: '#0000FF' }, + }); + const rendered = renderWithDependencies(, currentBed.injector); + root = rendered.root; + container = rendered.container; + + const editorHost = rendered.container.querySelector('[data-editor-id]')?.parentElement; + expect(editorHost?.style.backgroundColor).toBe('#000000'); + }); + + it('opens immersive mobile editing from the compact up arrow', async () => { + currentBed = createFormulaBarTestBed(); + const onExpandedChange = vi.fn(); + const rendered = renderWithDependencies( + , + currentBed.injector + ); + root = rendered.root; + container = rendered.container; + + const expandButton = rendered.container.querySelector('[data-u-comp="formula-bar-expand"]'); + const icon = expandButton?.querySelector('svg'); + expect(icon?.classList).toContain('univer-rotate-180'); + expect(icon?.classList).toContain('univer-size-5'); + + if (!expandButton) throw new Error('Expected the formula bar expand button to be rendered.'); + await clickElement(expandButton); + expect(onExpandedChange).toHaveBeenCalledWith(true); + }); + + it('commits, moves down, and collapses immersive mobile editing', async () => { + currentBed = createFormulaBarTestBed(); + const onExpandedChange = vi.fn(); + const rendered = renderWithDependencies( + , + currentBed.injector + ); + root = rendered.root; + container = rendered.container; + + await clickElement(getActionElement(rendered.container, 1)); + + expect(currentBed.mobileSubmit).toHaveBeenCalledOnce(); + expect(currentBed.editorBridgeService.visibleHistory.at(-1)?.visible).toBe(true); + expect(onExpandedChange).toHaveBeenCalledWith(false); + }); + + it('fills the mobile viewport while the formula bar is expanded', () => { + currentBed = createFormulaBarTestBed(); + const onExpandedChange = vi.fn(); + const rendered = renderWithDependencies( + , + currentBed.injector + ); + root = rendered.root; + container = rendered.container; + + const formulaBar = rendered.container.querySelector('[data-u-comp="formula-bar"]'); + const editorHost = rendered.container.querySelector('[data-editor-id]')?.parentElement; + expect(formulaBar?.getAttribute('data-expanded')).toBe('true'); + expect(formulaBar?.classList).toContain('!univer-h-full'); + expect(editorHost?.classList).not.toContain('univer-my-2'); + }); }); diff --git a/packages/sheets-ui/src/views/mobile/action-panel/MobileSheetActionPanel.tsx b/packages/sheets-ui/src/views/mobile/action-panel/MobileSheetActionPanel.tsx new file mode 100644 index 0000000000..0f1c120aec --- /dev/null +++ b/packages/sheets-ui/src/views/mobile/action-panel/MobileSheetActionPanel.tsx @@ -0,0 +1,567 @@ +/** + * 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 { Worksheet } from '@univerjs/core'; +import type { ISelectionWithStyle } from '@univerjs/sheets'; +import type { IValueOption, MobileDrawerSnap } from '@univerjs/ui'; +import type { LocaleKey } from '../../../locale/types'; +import type { IMobileStyleCommand, MobileStyleView } from './MobileStylePanel'; +import { + DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY, + EDITOR_ACTIVATED, + FOCUSING_COMMON_DRAWINGS, + FOCUSING_FX_BAR_EDITOR, + ICommandService, + IContextService, + LocaleService, +} from '@univerjs/core'; +import { clsx, resetButtonClassName } from '@univerjs/design'; +import { IEditorService } from '@univerjs/docs-ui'; +import { DeviceInputEventType } from '@univerjs/engine-render'; +import { KeyboardIcon, MenuIcon, MoreLeftIcon } from '@univerjs/icons'; +import { getPrimaryForRange, SheetsSelectionsService } from '@univerjs/sheets'; +import { + ContextMenuGroup, + ContextMenuPosition, + ILayoutService, + IMenuManagerService, + IRibbonService, + MobileDrawer, + MobileMenu, + RibbonPosition, + RibbonStartGroup, + useDependency, + useObservable, +} from '@univerjs/ui'; +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'; +import { + MOBILE_FORMULA_OPERATOR_BAR_HEIGHT, + MOBILE_FORMULA_OPERATORS_VISIBLE, + MOBILE_KEYBOARD_VISIBLE, +} from '../../../consts/mobile-context'; +import { IEditorBridgeService } from '../../../services/editor-bridge.service'; +import { useActiveWorkbook } from '../../hook'; +import { MobileStylePanel } from './MobileStylePanel'; + +type MobileSheetPanelTab = 'insert' | 'operation' | 'style'; +type MobileMenuSource = 'insert' | 'data' | 'operation'; + +interface IMobileMenuNavigation { + source: MobileMenuSource; + title?: string; + onBack: () => void; +} + +interface IMobileSheetPanelTab { + key: MobileSheetPanelTab; + title: LocaleKey; +} + +const MOBILE_SHEET_PANEL_TABS: IMobileSheetPanelTab[] = [ + { key: 'insert', title: 'sheets-ui.mobile.insert' }, + { key: 'operation', title: 'sheets-ui.mobile.operation' }, + { key: 'style', title: 'sheets-ui.mobile.style' }, +]; + +const MOBILE_CELL_SAFE_PADDING = 48; + +export function getMobileCellRevealOffset(cellTop: number, cellBottom: number, safeTop: number, safeBottom: number): number { + if (cellTop < safeTop) return cellTop - safeTop; + if (cellBottom > safeBottom) return cellBottom - safeBottom; + return 0; +} + +export function getMobileCellCenterOffset(cellTop: number, cellBottom: number, safeTop: number, safeBottom: number): number { + return (cellTop + cellBottom - safeTop - safeBottom) / 2; +} + +export function getMobileEditingMenuBottomOffset(formulaOperatorsVisible: boolean): number { + return 72 + (formulaOperatorsVisible ? MOBILE_FORMULA_OPERATOR_BAR_HEIGHT : 0); +} + +export function normalizeMobileSelectionPrimary( + selections: Readonly, + worksheet: Pick +): ISelectionWithStyle[] | null { + const lastIndex = selections.length - 1; + if (lastIndex < 0 || selections[lastIndex].primary) return null; + + return selections.map((selection, index) => ({ + range: { ...selection.range }, + primary: index === lastIndex + ? getPrimaryForRange(selection.range, worksheet) + : selection.primary ? { ...selection.primary } : null, + style: selection.style ? { ...selection.style } : selection.style, + })); +} + +export function getMobileMenuCommand(params: IValueOption | IMobileStyleCommand): { + commandId: string; + commandParams?: Record; +} | null { + const commandId = ('commandId' in params ? params.commandId : undefined) ?? params.id; + if (typeof commandId !== 'string') { + return null; + } + + if (!('params' in params)) { + return { + commandId, + commandParams: typeof params.value === 'undefined' ? undefined : { value: params.value }, + }; + } + + const fallbackParams = typeof params.params === 'function' + ? params.params(typeof params.value === 'string' || typeof params.value === 'number' ? params.value : undefined) + : params.params; + const commandParams = typeof params.value === 'undefined' ? fallbackParams : { value: params.value }; + + return { + commandId, + commandParams, + }; +} + +export function MobileSheetActionPanel() { + const workbook = useActiveWorkbook(); + const commandService = useDependency(ICommandService); + const contextService = useDependency(IContextService); + const editorBridgeService = useDependency(IEditorBridgeService); + const editorService = useDependency(IEditorService); + const layoutService = useDependency(ILayoutService); + const localeService = useDependency(LocaleService); + const menuManagerService = useDependency(IMenuManagerService); + const ribbonService = useDependency(IRibbonService); + const selectionManagerService = useDependency(SheetsSelectionsService); + const ribbon = useObservable(ribbonService.ribbon$, []); + const operationSchemas = useObservable( + () => menuManagerService.menuChanged$.pipe( + startWith(undefined), + map(() => menuManagerService.getMenuByPositionKey(ContextMenuPosition.MAIN_AREA).filter((schema) => + schema.key === ContextMenuGroup.QUICK || + schema.key === ContextMenuGroup.LAYOUT + )) + ), + [], + false, + [menuManagerService] + ); + const editing = useObservable( + () => editorBridgeService.visible$.pipe(map((state) => state.visible)), + false, + false, + [editorBridgeService] + ); + const keyboardVisible = useObservable( + () => contextService.subscribeContextValue$(MOBILE_KEYBOARD_VISIBLE).pipe(map(Boolean)), + Boolean(contextService.getContextValue(MOBILE_KEYBOARD_VISIBLE)), + false, + [contextService] + ); + const formulaOperatorsVisible = useObservable( + () => contextService.subscribeContextValue$(MOBILE_FORMULA_OPERATORS_VISIBLE).pipe(map(Boolean)), + Boolean(contextService.getContextValue(MOBILE_FORMULA_OPERATORS_VISIBLE)), + false, + [contextService] + ); + const focusingDrawing = useObservable( + () => contextService.subscribeContextValue$(FOCUSING_COMMON_DRAWINGS).pipe(map(Boolean)), + Boolean(contextService.getContextValue(FOCUSING_COMMON_DRAWINGS)), + false, + [contextService] + ); + const [activeTab, setActiveTab] = useState('insert'); + const [toolsOpen, setToolsOpen] = useState(false); + const [drawerSnap, setDrawerSnap] = useState('compact'); + const [styleViewStack, setStyleViewStack] = useState([]); + const [menuNavigation, setMenuNavigation] = useState(null); + const [recentColors, setRecentColors] = useState([]); + const panelRef = useRef(null); + const contentRef = useRef(null); + const currentStyleView = styleViewStack[styleViewStack.length - 1] ?? null; + const nestedTitle = currentStyleView?.title ?? menuNavigation?.title; + + const insertSchemas = useMemo( + () => ribbon.find((group) => group.key === RibbonPosition.INSERT)?.children?.flatMap((group) => group.children ?? []) ?? [], + [ribbon] + ); + const dataSchemas = useMemo( + () => ribbon.find((group) => group.key === RibbonPosition.DATA)?.children?.flatMap((group) => group.children ?? []) ?? [], + [ribbon] + ); + const styleGroups = useMemo(() => { + const startGroups = ribbon.find((group) => group.key === RibbonPosition.START)?.children ?? []; + return startGroups.filter((group) => + group.key === RibbonStartGroup.FORMAT || + group.key === RibbonStartGroup.LAYOUT || + group.key === RibbonStartGroup.NUMBER + ); + }, [ribbon]); + const createNavigationHandler = useCallback((source: MobileMenuSource) => ( + navigation: { title?: string; onBack: () => void } | null + ) => { + setMenuNavigation((current) => navigation + ? { ...navigation, source } + : current?.source === source ? null : current); + }, []); + const handleInsertNavigation = useMemo(() => createNavigationHandler('insert'), [createNavigationHandler]); + const handleDataNavigation = useMemo(() => createNavigationHandler('data'), [createNavigationHandler]); + const handleOperationNavigation = useMemo(() => createNavigationHandler('operation'), [createNavigationHandler]); + + const ensureActiveCellVisible = useCallback(async (safeBottom: number, center = false) => { + editorBridgeService.refreshEditCellPosition(false); + const state = editorBridgeService.getEditCellState(); + const selectionRange = selectionManagerService.getCurrentLastSelection()?.range; + if (!state && !selectionRange) return; + + if (state) { + const cellTop = state.canvasOffset.top + state.position.startY; + const cellBottom = state.canvasOffset.top + state.position.endY; + const safeTop = state.canvasOffset.top + MOBILE_CELL_SAFE_PADDING; + const targetBottom = safeBottom - MOBILE_CELL_SAFE_PADDING; + const offsetY = center + ? getMobileCellCenterOffset(cellTop, cellBottom, safeTop, targetBottom) + : getMobileCellRevealOffset(cellTop, cellBottom, safeTop, targetBottom); + if (offsetY === 0) return; + + await commandService.executeCommand(SetScrollRelativeCommand.id, { offsetY }); + editorBridgeService.refreshEditCellPosition(false); + return; + } + + const workbookModel = workbook; + const sheet = workbookModel?.getActiveSheet(); + if (!workbookModel || !sheet || !selectionRange) return; + const { ySplit } = sheet.getFreeze(); + await commandService.executeCommand(ScrollCommand.id, { + unitId: workbookModel.getUnitId(), + sheetId: sheet.getSheetId(), + sheetViewStartRow: Math.max(0, selectionRange.startRow - ySplit), + offsetY: 0, + }); + editorBridgeService.refreshEditCellPosition(false); + }, [commandService, editorBridgeService, selectionManagerService, workbook]); + + useEffect(() => { + if (!editing) return undefined; + + const visualViewport = window.visualViewport; + let frame = 0; + const revealEditingCell = () => { + cancelAnimationFrame(frame); + frame = requestAnimationFrame(() => { + const visibleBottom = visualViewport + ? visualViewport.offsetTop + visualViewport.height + : window.innerHeight; + ensureActiveCellVisible(visibleBottom - 48, keyboardVisible).catch(() => undefined); + }); + }; + revealEditingCell(); + visualViewport?.addEventListener('resize', revealEditingCell); + const selectionSubscription = selectionManagerService.selectionMoveEnd$.subscribe(revealEditingCell); + + return () => { + cancelAnimationFrame(frame); + visualViewport?.removeEventListener('resize', revealEditingCell); + selectionSubscription.unsubscribe(); + }; + }, [editing, ensureActiveCellVisible, keyboardVisible, selectionManagerService]); + + useEffect(() => { + if (!toolsOpen || drawerSnap === 'expanded') return undefined; + + const frame = requestAnimationFrame(() => { + const panelTop = panelRef.current?.getBoundingClientRect().top; + if (panelTop != null) { + ensureActiveCellVisible(panelTop).catch(() => undefined); + } + }); + return () => cancelAnimationFrame(frame); + }, [drawerSnap, ensureActiveCellVisible, toolsOpen]); + + useEffect(() => { + if (contentRef.current) contentRef.current.scrollTop = 0; + }, [activeTab, menuNavigation?.title, styleViewStack.length]); + + if (!workbook || focusingDrawing) { + return null; + } + + function closeTools() { + setToolsOpen(false); + setDrawerSnap('compact'); + setStyleViewStack([]); + setMenuNavigation(null); + } + + async function openTools() { + if (editing) { + await commandService.executeCommand(SetCellEditVisibleOperation.id, { + visible: false, + eventType: DeviceInputEventType.PointerDown, + unitId: workbook!.getUnitId(), + }); + } + contextService.setContextValue(EDITOR_ACTIVATED, false); + contextService.setContextValue(FOCUSING_FX_BAR_EDITOR, false); + ensureSelectionPrimary(); + setDrawerSnap('compact'); + setToolsOpen(true); + } + + function ensureSelectionPrimary() { + const selections = selectionManagerService.getCurrentSelections(); + const normalized = normalizeMobileSelectionPrimary(selections, workbook!.getActiveSheet()); + if (normalized) selectionManagerService.setSelections(normalized); + } + + function openKeyboard() { + closeTools(); + layoutService.focus(); + if (editing) { + contextService.setContextValue(FOCUSING_FX_BAR_EDITOR, true); + editorService.focus(DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY); + return; + } + + commandService.executeCommand(SetCellEditVisibleOperation.id, { + visible: true, + eventType: DeviceInputEventType.Dblclick, + unitId: workbook!.getUnitId(), + }).catch(() => undefined); + } + + function executeMenuItem(params: IValueOption | IMobileStyleCommand) { + const command = getMobileMenuCommand(params); + if (!command) { + return; + } + + ensureSelectionPrimary(); + commandService.executeCommand(command.commandId, command.commandParams); + } + + if (editing) { + return ( + + ); + } + + if (!toolsOpen) { + return ( +
    + + +
    + ); + } + + return ( + ('sheets-ui.mobile.openTools')} + collapseLabel={localeService.t('sheets-ui.mobile.closeTools')} + onSnapChange={setDrawerSnap} + onClose={closeTools} + floatingActions={( + + )} + header={nestedTitle + ? ( +
    + +
    + {nestedTitle} +
    +
    + ) + : ( +
    + {MOBILE_SHEET_PANEL_TABS.map((tab) => ( + + ))} +
    + )} + > + {activeTab === 'insert' && ( + + )} + {activeTab === 'style' && ( + { + setMenuNavigation(null); + setStyleViewStack((stack) => [...stack, view]); + }} + onBack={() => setStyleViewStack((stack) => stack.slice(0, -1))} + onExecute={executeMenuItem} + onUseColor={(color) => setRecentColors((colors) => [ + color, + ...colors.filter((item) => item.toUpperCase() !== color.toUpperCase()), + ].slice(0, 8))} + /> + )} + {activeTab === 'operation' && ( +
    + {dataSchemas.length > 0 && (!menuNavigation || menuNavigation.source === 'data') && ( + + )} + {(!menuNavigation || menuNavigation.source === 'operation') && ( + + )} +
    + )} +
    + ); +} diff --git a/packages/sheets-ui/src/views/mobile/action-panel/MobileStylePanel.tsx b/packages/sheets-ui/src/views/mobile/action-panel/MobileStylePanel.tsx new file mode 100644 index 0000000000..bb93d23264 --- /dev/null +++ b/packages/sheets-ui/src/views/mobile/action-panel/MobileStylePanel.tsx @@ -0,0 +1,1471 @@ +/** + * 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 { IBorderInfo } from '@univerjs/sheets'; +import type { IDisplayMenuItem, IFontConfig, IMenuItem, IMenuSchema, IMenuSelectorItem, IValueOption, MenuItemDefaultValueType } from '@univerjs/ui'; +import type { LocaleKey } from '../../../locale/types'; +import { BorderStyleTypes, BorderType, LocaleService, ThemeService } from '@univerjs/core'; +import { borderBottomClassName, clsx, ColorPickerPanel, MobileActionRow, MobileColorPresets, resetButtonClassName } from '@univerjs/design'; +import { CheckMarkIcon, MoreRightIcon, NoColorDoubleIcon } from '@univerjs/icons'; +import { + AddWorksheetMergeCommand, + ResetBackgroundColorCommand, + SetBackgroundColorCommand, + SetBorderBasicCommand, + SetHorizontalTextAlignCommand, + SetShrinkToFitCommand, + SetTextRotationCommand, + SetTextWrapCommand, + SetVerticalTextAlignCommand, +} from '@univerjs/sheets'; +import { + IconManager, + IFontService, + MenuItemType, + ToolbarItem, + useDependency, + useObservable, + useToolbarItemStatus, +} from '@univerjs/ui'; +import { useMemo, useState } from 'react'; +import { isObservable } from 'rxjs'; +import { + ResetRangeTextColorCommand, + SetRangeBoldCommand, + SetRangeFontFamilyCommand, + SetRangeItalicCommand, + SetRangeStrickThroughCommand, + SetRangeTextColorCommand, + SetRangeUnderlineCommand, +} from '../../../commands/commands/inline-format.command'; +import { BorderLine } from '../../border-panel/border-line/BorderLine'; +import { BORDER_LINE_CHILDREN, BORDER_SIZE_CHILDREN } from '../../border-panel/interface'; + +type ColorTarget = 'text' | 'background' | 'border'; + +const TEXT_STYLE_COMMANDS = new Set([ + SetRangeBoldCommand.id, + SetRangeItalicCommand.id, + SetRangeUnderlineCommand.id, + SetRangeStrickThroughCommand.id, +]); +const INLINE_SELECTOR_COMMANDS = new Set([ + SetHorizontalTextAlignCommand.id, + SetVerticalTextAlignCommand.id, + SetTextWrapCommand.id, +]); +const NAVIGATION_STYLE_COMMANDS = new Set([ + SetBorderBasicCommand.id, + SetRangeTextColorCommand.id, + SetBackgroundColorCommand.id, +]); +const SPECIAL_MOBILE_STYLE_COMMANDS = new Set([ + SetShrinkToFitCommand.id, + SetTextRotationCommand.id, + AddWorksheetMergeCommand.id, +]); +const BORDER_TYPES = new Set(Object.values(BorderType)); + +interface IMobileNumberFormatOption { + label?: string; + commandId?: string; + value?: string | null; + divider?: boolean; + custom?: boolean; +} + +interface IMobileNumberFormatMenuConfig { + kind: 'number-format'; + title: string; + commandId: string; + detailTitle: string; + customTitle: string; + quickOptions: IMobileNumberFormatOption[]; + decimalOptions: IMobileNumberFormatOption[]; + detailOptions: IMobileNumberFormatOption[]; + customPatterns: string[]; +} + +export type MobileNumberFormatItem = IDisplayMenuItem> & { + mobileStyle?: IMobileNumberFormatMenuConfig; +}; + +type ConfiguredMobileNumberFormatItem = MobileNumberFormatItem & { + mobileStyle: IMobileNumberFormatMenuConfig; +}; + +type MenuSchemaWithItem = IMenuSchema & { + item: IMenuItem; +}; + +type SelectorMenuSchema = IMenuSchema & { + item: IDisplayMenuItem>; +}; + +export interface IMobileStyleCommand { + id: string; + value?: unknown; +} + +export type MobileStyleView = + | { + kind: 'options'; + title: string; + item: IDisplayMenuItem>; + } + | { + kind: 'color'; + target: Exclude; + title: string; + item: IDisplayMenuItem; + } + | { + kind: 'custom-color'; + target: ColorTarget; + title: string; + value?: string; + borderValue?: IBorderInfo; + item: IDisplayMenuItem; + } + | { + kind: 'border'; + title: string; + item: IDisplayMenuItem; + } + | { + kind: 'border-color'; + title: string; + item: IDisplayMenuItem; + } + | { + kind: 'border-style'; + title: string; + item: IDisplayMenuItem; + } + | { + kind: 'number-format'; + title: string; + item: MobileNumberFormatItem; + config: IMobileNumberFormatMenuConfig; + } + | { + kind: 'custom-number-format'; + title: string; + item: MobileNumberFormatItem; + config: IMobileNumberFormatMenuConfig; + }; + +export function MobileStylePanel(props: { + groups: IMenuSchema[]; + currentView: MobileStyleView | null; + recentColors: string[]; + onOpenView: (view: MobileStyleView) => void; + onBack: () => void; + onExecute: (params: IMobileStyleCommand) => void; + onUseColor: (color: string) => void; +}) { + const { groups, currentView, recentColors, onOpenView, onBack, onExecute, onUseColor } = props; + const localeService = useDependency(LocaleService); + const themeService = useDependency(ThemeService); + const borderView = currentView?.kind === 'border' || currentView?.kind === 'border-color' || currentView?.kind === 'border-style' + ? currentView + : null; + const currentValue = useObservable(borderView?.item.value$, undefined); + const currentBorderInfo = isBorderInfo(currentValue) ? currentValue : undefined; + const customColorTitle = localeService.t('sheets-ui.mobile.customColor'); + + if (!currentView) { + return ; + } + + if (currentView.kind === 'custom-color') { + return ( + ('sheets-ui.mobile.applyColor')} + onConfirm={(color) => { + executeColor(currentView.target, color, onExecute, currentView.borderValue); + onUseColor(color); + onBack(); + }} + /> + ); + } + + if (currentView.kind === 'color') { + return ( + + ); + } + + if (currentView.kind === 'options') { + return ; + } + + if (currentView.kind === 'number-format') { + return ( + + ); + } + + if (currentView.kind === 'custom-number-format') { + return ; + } + + const value: IBorderInfo = currentBorderInfo ?? { + type: BorderType.ALL, + color: themeService.getColorFromTheme('gray.900'), + style: BorderStyleTypes.THIN, + activeBorderType: false, + }; + + if (currentView.kind === 'border-color') { + return ( + + ); + } + + if (currentView.kind === 'border-style') { + return ; + } + + return ( + ('sheets-ui.mobile.borderColor')} + borderStyleTitle={localeService.t('sheets-ui.mobile.borderStyle')} + onOpenView={onOpenView} + onExecute={onExecute} + /> + ); +} + +function MobileStyleRoot(props: { + groups: IMenuSchema[]; + onOpenView: (view: MobileStyleView) => void; + onExecute: (params: IMobileStyleCommand) => void; +}) { + const { groups, onOpenView, onExecute } = props; + const localeService = useDependency(LocaleService); + + return ( +
    + {groups.map((group) => { + const items = group.children?.filter(hasMenuItem) ?? []; + if (items.length === 0) return null; + const textStyleItems = items.filter((schema) => TEXT_STYLE_COMMANDS.has(schema.item.id)); + const inlineSelectorItems = items + .filter(isMobileSelectorSchema) + .filter((schema) => INLINE_SELECTOR_COMMANDS.has(schema.item.id)); + const navigationItems = items.filter((schema) => NAVIGATION_STYLE_COMMANDS.has(schema.item.id)); + const rotationSchema = items.find((schema): schema is SelectorMenuSchema => + schema.item.id === SetTextRotationCommand.id && isMobileSelectorItem(schema.item)); + const mergeSchema = items.find((schema) => schema.item.id === AddWorksheetMergeCommand.id); + const numberFormatSchema = items.find(isMobileNumberFormatSchema); + const numberFormatConfig = numberFormatSchema?.item.mobileStyle; + const numberFormatCommandIds = new Set([ + ...(numberFormatConfig?.quickOptions ?? []), + ...(numberFormatConfig?.decimalOptions ?? []), + ].map((option) => option.commandId)); + const gridItems = items.filter((schema) => + !TEXT_STYLE_COMMANDS.has(schema.item.id) && + !INLINE_SELECTOR_COMMANDS.has(schema.item.id) && + !NAVIGATION_STYLE_COMMANDS.has(schema.item.id) && + !SPECIAL_MOBILE_STYLE_COMMANDS.has(schema.item.id) && + !numberFormatCommandIds.has(schema.item.id) && + schema !== numberFormatSchema + ); + + return ( +
    + {group.title && ( +
    + {localeService.t(group.title)} +
    + )} + {textStyleItems.length > 0 && ( +
    + {textStyleItems.map((schema) => ( + + ))} +
    + )} + {inlineSelectorItems.map((schema) => ( + + ))} + {rotationSchema && ( + + )} + {mergeSchema && ( + + )} + {numberFormatSchema && ( + + )} + {gridItems.length > 0 && ( +
    + {gridItems.map((schema) => ( + + ))} +
    + )} + {navigationItems.length > 0 && ( +
    + {navigationItems.map((schema, index) => ( + + ))} +
    + )} +
    + ); + })} +
    + ); +} + +function MobileRotationNavigation(props: { + schema: SelectorMenuSchema; + onOpenView: (view: MobileStyleView) => void; +}) { + const { schema, onOpenView } = props; + const item = schema.item; + const localeService = useDependency(LocaleService); + const status = useToolbarItemStatus(item); + const selections = Array.isArray(item.selections) ? item.selections : []; + const selected = selections.find((option) => option.value === status.value); + const titleKey = item.title ?? item.tooltip; + const title = typeof titleKey === 'string' ? localeService.t(titleKey) : schema.key; + const value = selected ? getOptionLabel(localeService, selected) : undefined; + + if (status.hidden) return null; + + return ( +
    + } + onClick={() => onOpenView({ kind: 'options', title, item })} + /> +
    + ); +} + +function MobileMergeGroup(props: { + schema: MenuSchemaWithItem; + onExecute: (params: IMobileStyleCommand) => void; +}) { + const { schema, onExecute } = props; + const item = schema.item; + const localeService = useDependency(LocaleService); + const status = useToolbarItemStatus(item); + const titleKey = item.title ?? item.tooltip; + const title = typeof titleKey === 'string' ? localeService.t(titleKey) : schema.key; + const children = schema.children?.filter(hasMenuItem) ?? []; + + if (status.hidden || children.length === 0) return null; + + return ( +
    +
    + {title} +
    +
    + {children.map((child) => ( + + ))} +
    +
    + ); +} + +function MobileMergeOption(props: { + schema: MenuSchemaWithItem; + parentDisabled: boolean; + onExecute: (params: IMobileStyleCommand) => void; +}) { + const { schema, parentDisabled, onExecute } = props; + const item = schema.item; + const localeService = useDependency(LocaleService); + const status = useToolbarItemStatus(item); + const titleKey = item.title ?? item.tooltip; + const title = typeof titleKey === 'string' ? localeService.t(titleKey) : schema.key; + + if (status.hidden) return null; + + return ( + + ); +} + +function MobileNumberFormatGroup(props: { + schemas: IMenuSchema[]; + schema: MenuSchemaWithItem & { item: ConfiguredMobileNumberFormatItem }; + onOpenView: (view: MobileStyleView) => void; + onExecute: (params: IMobileStyleCommand) => void; +}) { + const { schemas, schema, onOpenView, onExecute } = props; + const item = schema.item; + const config = item.mobileStyle; + const localeService = useDependency(LocaleService); + const status = useToolbarItemStatus(item); + + if (status.hidden) return null; + + return ( +
    +
    + {localeService.t(config.title)} +
    + + +
    + } + onClick={() => onOpenView({ + kind: 'number-format', + title: localeService.t(config.detailTitle), + item, + config, + })} + /> +
    +
    + ); +} + +function MobileNumberFormatTabs(props: { + options: IMobileNumberFormatOption[]; + schemas: IMenuSchema[]; + parentItem: MobileNumberFormatItem; + currentValue: unknown; + onExecute: (params: IMobileStyleCommand) => void; +}) { + const { options, schemas, parentItem, currentValue, onExecute } = props; + + return ( +
    + {options.map((option) => { + const schema = schemas.find((candidate) => candidate.item?.id === option.commandId); + return ( + + ); + })} +
    + ); +} + +function MobileNumberFormatTab(props: { + option: IMobileNumberFormatOption; + statusItem: IDisplayMenuItem; + currentValue: unknown; + onExecute: (params: IMobileStyleCommand) => void; +}) { + const { option, statusItem, currentValue, onExecute } = props; + const localeService = useDependency(LocaleService); + const status = useToolbarItemStatus(statusItem); + const title = option.label ? localeService.t(option.label) : ''; + const selected = title === currentValue; + + if (status.hidden) return null; + + return ( + + ); +} + +function MobileNumberFormatDetails(props: { + item: MobileNumberFormatItem; + config: IMobileNumberFormatMenuConfig; + onOpenView: (view: MobileStyleView) => void; + onExecute: (params: IMobileStyleCommand) => void; +}) { + const { item, config, onOpenView, onExecute } = props; + const localeService = useDependency(LocaleService); + const status = useToolbarItemStatus(item); + + return ( +
    + {config.detailOptions.map((option, index) => { + if (option.divider) { + return ( +
    + ); + } + + const title = option.label ? localeService.t(option.label) : ''; + const selected = title === status.value; + return ( + + ); + })} +
    + ); +} + +function MobileCustomNumberFormat(props: { + config: IMobileNumberFormatMenuConfig; + onBack: () => void; + onExecute: (params: IMobileStyleCommand) => void; +}) { + const { config, onBack, onExecute } = props; + const localeService = useDependency(LocaleService); + const [pattern, setPattern] = useState(''); + const title = localeService.t(config.customTitle); + + return ( +
    +
    + + setPattern(event.target.value)} + /> +
    +
    + {config.customPatterns.map((item, index) => ( + + ))} +
    + +
    + {localeService.t('sheets-ui.mobile.customFormatDescription')} +
    +
    + ); +} + +function getOptionLabel(localeService: LocaleService, option: IValueOption): string { + return typeof option.label === 'string' ? localeService.t(option.label) : String(option.value ?? ''); +} + +function MobileTextStyleItem(props: { + schema: MenuSchemaWithItem; + onExecute: (params: IMobileStyleCommand) => void; +}) { + const { schema, onExecute } = props; + const item = schema.item; + const iconManager = useDependency(IconManager); + const localeService = useDependency(LocaleService); + const status = useToolbarItemStatus(item); + const iconName = typeof item.icon === 'string' ? item.icon : undefined; + const Icon = iconName ? iconManager.get(iconName) : null; + const titleKey = item.title ?? item.tooltip; + const title = typeof titleKey === 'string' ? localeService.t(titleKey) : schema.key; + if (status.hidden) return null; + + return ( + + ); +} + +function MobileInlineSelector(props: { schema: SelectorMenuSchema; onExecute: (params: IMobileStyleCommand) => void }) { + const { schema, onExecute } = props; + const item = schema.item; + const status = useToolbarItemStatus(item); + const selections$ = useMemo(() => isObservable(item.selections) ? item.selections : undefined, [item.selections]); + const observableSelections = useObservable(selections$); + const selections = observableSelections ?? (Array.isArray(item.selections) ? item.selections : []); + const selectedValue = status.value || (item.id === SetVerticalTextAlignCommand.id + ? selections[selections.length - 1]?.value + : selections[0]?.value); + + if (status.hidden || selections.length === 0) return null; + + return ( +
    + {selections.map((option, index) => ( + + ))} +
    + ); +} + +function MobileInlineSelectorOption(props: { + item: IDisplayMenuItem>; + option: IValueOption; + selectedValue: unknown; + disabled: boolean; + onExecute: (params: IMobileStyleCommand) => void; +}) { + const { item, option, selectedValue, disabled, onExecute } = props; + const iconManager = useDependency(IconManager); + const localeService = useDependency(LocaleService); + const observableValue = useObservable(option.value$); + const value = option.value ?? observableValue; + const Icon = option.icon ? iconManager.get(option.icon) : null; + const selected = value === selectedValue; + const label = typeof option.label === 'string' ? localeService.t(option.label) : String(value ?? ''); + + return ( + + ); +} + +function MobileStyleNavigationItem(props: { + schema: MenuSchemaWithItem; + bordered: boolean; + onOpenView: (view: MobileStyleView) => void; +}) { + const { schema, bordered, onOpenView } = props; + const item = schema.item; + const localeService = useDependency(LocaleService); + const status = useToolbarItemStatus(item); + const titleKey = item.title ?? item.tooltip; + const title = typeof titleKey === 'string' ? localeService.t(titleKey) : schema.key; + const target = item.id === SetRangeTextColorCommand.id ? 'text' : 'background'; + + if (status.hidden) return null; + + return ( + } + onClick={() => onOpenView(item.id === SetBorderBasicCommand.id + ? { kind: 'border', title, item } + : { kind: 'color', target, title, item })} + /> + ); +} + +function MobileStyleRootItem(props: { schema: MenuSchemaWithItem; onOpenView: (view: MobileStyleView) => void }) { + const { schema, onOpenView } = props; + const item = schema.item; + const localeService = useDependency(LocaleService); + const iconManager = useDependency(IconManager); + const status = useToolbarItemStatus(item); + const icon$ = useMemo(() => isObservable(item.icon) ? item.icon : undefined, [item.icon]); + const observableIcon = useObservable(icon$); + const iconName = observableIcon ?? (typeof item.icon === 'string' ? item.icon : undefined); + const Icon = iconName ? iconManager.get(iconName) : null; + const titleKey = item.title ?? item.tooltip; + const title = typeof titleKey === 'string' ? localeService.t(titleKey) : schema.key; + const displayValue = typeof status.value === 'string' || typeof status.value === 'number' + ? status.value + : undefined; + const target = item.id === SetRangeTextColorCommand.id + ? 'text' + : item.id === SetBackgroundColorCommand.id + ? 'background' + : null; + const isBorder = item.id === SetBorderBasicCommand.id; + + if ( + !target && + !isBorder && + isMobileSelectorItem(item) + ) { + if (status.hidden) return null; + return ( + + ); + } + + if (!target && !isBorder) { + return ( +
    + +
    + ); + } + + if (status.hidden) return null; + + return ( + + ); +} + +function MobileStyleOptionsView(props: { + item: IDisplayMenuItem>; + onExecute: (params: IMobileStyleCommand) => void; +}) { + const { item, onExecute } = props; + const iconManager = useDependency(IconManager); + const localeService = useDependency(LocaleService); + const status = useToolbarItemStatus(item); + const selections$ = useMemo(() => isObservable(item.selections) ? item.selections : undefined, [item.selections]); + const observableSelections = useObservable(selections$); + const selections = observableSelections ?? (Array.isArray(item.selections) ? item.selections : []); + + if (item.id === SetRangeFontFamilyCommand.id) { + return ; + } + + return ( +
    + {selections.map((option, index) => { + const Icon = option.icon ? iconManager.get(option.icon) : null; + const selected = option.value === status.value; + const label = getOptionLabel(localeService, option); + return ( + + ); + })} +
    + ); +} + +function MobileFontFamilyOptions(props: { + item: IDisplayMenuItem>; + onExecute: (params: IMobileStyleCommand) => void; +}) { + const { item, onExecute } = props; + const fontService = useDependency(IFontService); + const localeService = useDependency(LocaleService); + const status = useToolbarItemStatus(item); + const fonts = useObservable(fontService.fonts$, fontService.getFonts()); + + return ( +
    + {fonts.map((font, index) => { + const selected = font.value === status.value; + return ( + + ); + })} +
    + ); +} + +function MobileColorView(props: { + target: Exclude; + item: IDisplayMenuItem; + recentColors: string[]; + customColorTitle: string; + onOpenView: (view: MobileStyleView) => void; + onExecute: (params: IMobileStyleCommand) => void; + onUseColor: (color: string) => void; +}) { + const { target, item, recentColors, customColorTitle, onOpenView, onExecute, onUseColor } = props; + const localeService = useDependency(LocaleService); + const { value } = useToolbarItemStatus(item); + const [selectedColor, setSelectedColor] = useState(typeof value === 'string' ? value : undefined); + + function selectColor(color: string) { + executeColor(target, color, onExecute); + setSelectedColor(color); + onUseColor(color); + } + + return ( +
    + } + title={localeService.t('sheets-ui.toolbar.resetColor')} + onClick={() => { + onExecute({ id: target === 'text' ? ResetRangeTextColorCommand.id : ResetBackgroundColorCommand.id }); + setSelectedColor(undefined); + }} + /> + + {recentColors.length > 0 && ( +
    +
    + {localeService.t('sheets-ui.mobile.recentColors')} +
    +
    + {recentColors.map((color) => ( + selectColor(color)} /> + ))} +
    +
    + )} + } + onClick={() => onOpenView({ + kind: 'custom-color', + target, + title: customColorTitle, + value: selectedColor, + item, + })} + /> +
    + ); +} + +function MobileBorderView(props: { + item: IDisplayMenuItem; + value: IBorderInfo; + borderColorTitle: string; + borderStyleTitle: string; + onOpenView: (view: MobileStyleView) => void; + onExecute: (params: IMobileStyleCommand) => void; +}) { + const { item, value, borderColorTitle, borderStyleTitle, onOpenView, onExecute } = props; + const iconManager = useDependency(IconManager); + const localeService = useDependency(LocaleService); + + function apply(next: Partial) { + onExecute({ id: SetBorderBasicCommand.id, value: { ...value, ...next } }); + } + + return ( +
    +
    + {BORDER_LINE_CHILDREN.map((option) => { + const Icon = iconManager.get(option.icon); + const selected = value.type === option.value; + return ( + + ); + })} +
    +
    + } + onClick={() => onOpenView({ kind: 'border-color', title: borderColorTitle, item })} + /> + } + onClick={() => onOpenView({ kind: 'border-style', title: borderStyleTitle, item })} + /> +
    +
    + ); +} + +function MobileBorderColorView(props: { + item: IDisplayMenuItem; + value: IBorderInfo; + recentColors: string[]; + defaultColor: string; + customColorTitle: string; + onOpenView: (view: MobileStyleView) => void; + onExecute: (params: IMobileStyleCommand) => void; + onUseColor: (color: string) => void; +}) { + const { item, value, recentColors, defaultColor, customColorTitle, onOpenView, onExecute, onUseColor } = props; + const localeService = useDependency(LocaleService); + + function selectColor(color: string) { + onExecute({ id: SetBorderBasicCommand.id, value: { ...value, color } }); + onUseColor(color); + } + + return ( +
    + } + title={localeService.t('sheets-ui.toolbar.resetColor')} + onClick={() => onExecute({ id: SetBorderBasicCommand.id, value: { ...value, color: defaultColor } })} + /> + + {recentColors.length > 0 && ( +
    + {recentColors.map((color) => ( + selectColor(color)} /> + ))} +
    + )} + } + onClick={() => onOpenView({ + kind: 'custom-color', + target: 'border', + title: customColorTitle, + value: value.color, + borderValue: value, + item, + })} + /> +
    + ); +} + +function MobileBorderStyleView(props: { + value: IBorderInfo; + onExecute: (params: IMobileStyleCommand) => void; +}) { + const { value, onExecute } = props; + + return ( +
    + {BORDER_SIZE_CHILDREN.map((option, index) => ( + + ))} +
    + ); +} + +function ColorButton(props: { color: string; selected: boolean; onClick: () => void }) { + const { color, selected, onClick } = props; + return ( + + ); +} + +function executeColor( + target: ColorTarget, + color: string, + onExecute: (params: IMobileStyleCommand) => void, + borderValue?: IBorderInfo +) { + if (target === 'border') { + if (borderValue) { + onExecute({ id: SetBorderBasicCommand.id, value: { ...borderValue, color } }); + } + return; + } + + onExecute({ id: target === 'text' ? SetRangeTextColorCommand.id : SetBackgroundColorCommand.id, value: color }); +} + +function isBorderInfo(value: unknown): value is IBorderInfo { + if (!value || typeof value !== 'object') return false; + + return 'type' in value + && BORDER_TYPES.has(value.type) + && 'color' in value + && (typeof value.color === 'string' || value.color === undefined) + && 'style' in value + && typeof value.style === 'number' + && 'activeBorderType' in value + && typeof value.activeBorderType === 'boolean'; +} + +function hasMenuItem(schema: IMenuSchema): schema is MenuSchemaWithItem { + return schema.item !== undefined; +} + +function isMobileSelectorItem(item: IMenuItem): item is SelectorMenuSchema['item'] { + return item.type !== MenuItemType.BUTTON; +} + +function isMobileSelectorSchema(schema: MenuSchemaWithItem): schema is SelectorMenuSchema { + return isMobileSelectorItem(schema.item); +} + +function isMobileNumberFormatSchema( + schema: MenuSchemaWithItem +): schema is MenuSchemaWithItem & { item: ConfiguredMobileNumberFormatItem } { + if (!isMobileSelectorItem(schema.item) || !('mobileStyle' in schema.item)) return false; + + const config = schema.item.mobileStyle; + if (!config || typeof config !== 'object') return false; + + return 'kind' in config + && config.kind === 'number-format' + && 'title' in config + && typeof config.title === 'string' + && 'commandId' in config + && typeof config.commandId === 'string' + && 'detailTitle' in config + && typeof config.detailTitle === 'string' + && 'customTitle' in config + && typeof config.customTitle === 'string' + && 'quickOptions' in config + && Array.isArray(config.quickOptions) + && 'decimalOptions' in config + && Array.isArray(config.decimalOptions) + && 'detailOptions' in config + && Array.isArray(config.detailOptions) + && 'customPatterns' in config + && Array.isArray(config.customPatterns); +} diff --git a/packages/sheets-ui/src/views/mobile/action-panel/__tests__/MobileSheetActionPanel.spec.ts b/packages/sheets-ui/src/views/mobile/action-panel/__tests__/MobileSheetActionPanel.spec.ts new file mode 100644 index 0000000000..0e47770c62 --- /dev/null +++ b/packages/sheets-ui/src/views/mobile/action-panel/__tests__/MobileSheetActionPanel.spec.ts @@ -0,0 +1,123 @@ +/** + * Copyright 2023-present DreamNum Co., Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, expect, it } from 'vitest'; +import { + getMobileCellCenterOffset, + getMobileCellRevealOffset, + getMobileEditingMenuBottomOffset, + getMobileMenuCommand, + normalizeMobileSelectionPrimary, +} from '../MobileSheetActionPanel'; + +describe('mobile active-cell reveal offset', () => { + it('moves only the distance outside the padded visible area', () => { + expect(getMobileCellRevealOffset(100, 130, 80, 500)).toBe(0); + expect(getMobileCellRevealOffset(60, 90, 80, 500)).toBe(-20); + expect(getMobileCellRevealOffset(490, 530, 80, 500)).toBe(30); + }); + + it('centers the active cell in the area above the software keyboard', () => { + expect(getMobileCellCenterOffset(100, 140, 80, 500)).toBe(-170); + expect(getMobileCellCenterOffset(280, 320, 80, 520)).toBe(0); + expect(getMobileCellCenterOffset(460, 500, 80, 520)).toBe(180); + }); +}); + +describe('mobile editing menu placement', () => { + it('moves above the compact formula operator strip while it is visible', () => { + expect(getMobileEditingMenuBottomOffset(false)).toBe(72); + expect(getMobileEditingMenuBottomOffset(true)).toBe(108); + }); +}); + +describe('mobile style selection normalization', () => { + it('restores the last selection primary before running shared style commands', () => { + const selections = [ + { + range: { startRow: 3, endRow: 4, startColumn: 2, endColumn: 5 }, + primary: null, + style: null, + }, + ]; + const worksheet = { getMergedCell: () => null }; + + const normalized = normalizeMobileSelectionPrimary(selections, worksheet); + + expect(normalized?.[0].primary).toMatchObject({ + actualRow: 3, + actualColumn: 2, + startRow: 3, + startColumn: 2, + }); + expect(selections[0].primary).toBeNull(); + }); + + it('keeps an existing primary unchanged', () => { + const selections = [{ + range: { startRow: 1, endRow: 1, startColumn: 1, endColumn: 1 }, + primary: { + actualRow: 1, + actualColumn: 1, + startRow: 1, + endRow: 1, + startColumn: 1, + endColumn: 1, + isMerged: false, + isMergedMainCell: false, + }, + style: null, + }]; + + expect(normalizeMobileSelectionPrimary(selections, { getMergedCell: () => null })).toBeNull(); + }); +}); + +describe('mobile menu command mapping', () => { + it.each([ + { + name: 'plain button command', + input: { id: 'copy', params: { from: 'mobile' } }, + expected: { commandId: 'copy', commandParams: { from: 'mobile' } }, + }, + { + name: 'explicit command alias', + input: { id: 'menu.copy', commandId: 'sheet.copy', params: { from: 'mobile' } }, + expected: { commandId: 'sheet.copy', commandParams: { from: 'mobile' } }, + }, + { + name: 'selector value', + input: { id: 'align', commandId: 'set-align', value: 'center' }, + expected: { commandId: 'set-align', commandParams: { value: 'center' } }, + }, + { + name: 'boolean style value', + input: { id: 'set-bold', value: false }, + expected: { commandId: 'set-bold', commandParams: { value: false } }, + }, + { + name: 'selector params factory', + input: { id: 'set-format', value: undefined, params: (value?: string | number) => ({ pattern: value ?? 'general' }) }, + expected: { commandId: 'set-format', commandParams: { pattern: 'general' } }, + }, + ])('maps $name', ({ input, expected }) => { + expect(getMobileMenuCommand(input)).toEqual(expected); + }); + + it('ignores a menu entry without an executable command id', () => { + expect(getMobileMenuCommand({ id: undefined })).toBeNull(); + }); +}); diff --git a/packages/sheets-ui/src/views/mobile/action-panel/__tests__/MobileStylePanel.spec.tsx b/packages/sheets-ui/src/views/mobile/action-panel/__tests__/MobileStylePanel.spec.tsx new file mode 100644 index 0000000000..6e354037d6 --- /dev/null +++ b/packages/sheets-ui/src/views/mobile/action-panel/__tests__/MobileStylePanel.spec.tsx @@ -0,0 +1,629 @@ +/** + * 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 { IDisplayMenuItem, IMenuItem, IMenuSchema } from '@univerjs/ui'; +import type { ComponentProps } from 'react'; +import type { Root } from 'react-dom/client'; +import type { MobileNumberFormatItem } from '../MobileStylePanel'; +import { + BorderStyleTypes, + BorderType, + ConfigService, + DesktopLogService, + HorizontalAlign, + IConfigService, + ILogService, + Injector, + LocaleService, + LocaleType, + ThemeService, + VerticalAlign, +} from '@univerjs/core'; +import { + AddWorksheetMergeAllCommand, + AddWorksheetMergeCommand, + AddWorksheetMergeHorizontalCommand, + AddWorksheetMergeVerticalCommand, + RemoveWorksheetMergeCommand, + ResetBackgroundColorCommand, + SetBackgroundColorCommand, + SetBorderBasicCommand, + SetHorizontalTextAlignCommand, + SetShrinkToFitCommand, + SetTextRotationCommand, + SetTextWrapCommand, + SetVerticalTextAlignCommand, +} from '@univerjs/sheets'; +import { FontService, IconManager, IFontService, MenuItemType, RediContext } from '@univerjs/ui'; +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { BehaviorSubject } from 'rxjs'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + ResetRangeTextColorCommand, + SetRangeBoldCommand, + SetRangeFontFamilyCommand, + SetRangeItalicCommand, + SetRangeStrickThroughCommand, + SetRangeTextColorCommand, + SetRangeUnderlineCommand, +} from '../../../../commands/commands/inline-format.command'; +import { BORDER_LINE_CHILDREN, BORDER_SIZE_CHILDREN } from '../../../border-panel/interface'; +import { MobileStylePanel } from '../MobileStylePanel'; + +function renderStylePanel(props: Partial> = {}) { + const injector = new Injector(); + injector.add([LocaleService]); + injector.add([ThemeService]); + injector.add([ILogService, { useClass: DesktopLogService }]); + injector.add([IConfigService, { useClass: ConfigService }]); + injector.add([IconManager]); + injector.add([IFontService, { useClass: FontService }]); + injector.get(LocaleService).load({ [LocaleType.ZH_CN]: {} }); + const resolvedProps: ComponentProps = { + groups: [], + currentView: null, + recentColors: [], + onOpenView: vi.fn(), + onBack: vi.fn(), + onExecute: vi.fn(), + onUseColor: vi.fn(), + ...props, + }; + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + act(() => { + root.render( + + + + ); + }); + + return { + container, + injector, + root, + props: resolvedProps, + }; +} + +function getButton(container: HTMLElement, name: string): HTMLButtonElement { + const button = Array.from(container.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; +} + +function clickButton(container: HTMLElement, name: string): HTMLButtonElement { + const button = getButton(container, name); + act(() => button.dispatchEvent(new MouseEvent('click', { bubbles: true }))); + return button; +} + +let root: Root | undefined; +let container: HTMLDivElement | undefined; + +afterEach(() => { + act(() => root?.unmount()); + container?.remove(); + root = undefined; + container = undefined; +}); + +describe('MobileStylePanel', () => { + it('keeps BIUS controls independently selected and clickable', () => { + const commandIds = [ + SetRangeBoldCommand.id, + SetRangeItalicCommand.id, + SetRangeUnderlineCommand.id, + SetRangeStrickThroughCommand.id, + ]; + const groups: IMenuSchema[] = [{ + key: 'text-style', + order: 0, + children: commandIds.map((id) => ({ + key: id, + order: 0, + item: { + id, + type: MenuItemType.BUTTON, + title: id, + activated$: new BehaviorSubject(true), + }, + })), + }]; + const onExecute = vi.fn(); + const rendered = renderStylePanel({ groups, onExecute }); + root = rendered.root; + container = rendered.container; + + commandIds.forEach((id) => { + const button = getButton(rendered.container, id); + expect(button.getAttribute('aria-pressed')).toBe('true'); + clickButton(rendered.container, id); + }); + expect(onExecute.mock.calls.map(([command]) => command.id)).toEqual(commandIds); + }); + + it('renders the font list and applies the selected family', () => { + const onExecute = vi.fn(); + const rendered = renderStylePanel({ + currentView: { + kind: 'options', + title: 'Font', + item: { + id: SetRangeFontFamilyCommand.id, + type: MenuItemType.SELECTOR, + selections: [], + selectionsCommandId: SetRangeFontFamilyCommand.id, + value$: new BehaviorSubject('Arial'), + }, + }, + onExecute, + }); + root = rendered.root; + container = rendered.container; + + expect(getButton(rendered.container, 'Arial').getAttribute('aria-pressed')).toBe('true'); + clickButton(rendered.container, 'Times New Roman'); + expect(onExecute).toHaveBeenCalledWith({ + id: SetRangeFontFamilyCommand.id, + value: 'Times New Roman', + }); + }); + + it('shows the current font family and size on the style root', () => { + const onOpenView = vi.fn(); + const groups: IMenuSchema[] = [{ + key: 'font', + order: 0, + children: [ + { + key: SetRangeFontFamilyCommand.id, + order: 0, + item: { + id: SetRangeFontFamilyCommand.id, + type: MenuItemType.SELECTOR, + tooltip: 'Font family', + selections: [], + value$: new BehaviorSubject('Arial'), + }, + }, + { + key: 'font-size', + order: 1, + item: { + id: 'font-size', + type: MenuItemType.SELECTOR, + tooltip: 'Font size', + selections: [], + value$: new BehaviorSubject(10), + }, + }, + ], + }]; + const rendered = renderStylePanel({ groups, onOpenView }); + root = rendered.root; + container = rendered.container; + + expect(getButton(rendered.container, 'Font family').textContent).toContain('Arial'); + expect(getButton(rendered.container, 'Font size').textContent).toContain('10'); + clickButton(rendered.container, 'Font family'); + expect(onOpenView).toHaveBeenCalledWith(expect.objectContaining({ kind: 'options', title: 'Font family' })); + }); + + it('shows effective alignment defaults and follows value changes', () => { + const horizontalValue$ = new BehaviorSubject(HorizontalAlign.UNSPECIFIED); + const verticalValue$ = new BehaviorSubject(VerticalAlign.UNSPECIFIED); + const groups: IMenuSchema[] = [{ + key: 'alignment', + order: 0, + children: [ + { + key: 'horizontal', + order: 0, + item: { + id: SetHorizontalTextAlignCommand.id, + type: MenuItemType.SELECTOR, + selections: [ + { label: 'Left', value: HorizontalAlign.LEFT }, + { label: 'Center', value: HorizontalAlign.CENTER }, + { label: 'Right', value: HorizontalAlign.RIGHT }, + ], + value$: horizontalValue$, + }, + }, + { + key: 'vertical', + order: 1, + item: { + id: SetVerticalTextAlignCommand.id, + type: MenuItemType.SELECTOR, + selections: [ + { label: 'Top', value: VerticalAlign.TOP }, + { label: 'Middle', value: VerticalAlign.MIDDLE }, + { label: 'Bottom', value: VerticalAlign.BOTTOM }, + ], + value$: verticalValue$, + }, + }, + ], + }]; + const onExecute = vi.fn(); + groups[0].children!.push({ + key: 'wrap', + order: 2, + item: { + id: SetTextWrapCommand.id, + type: MenuItemType.SELECTOR, + selections: [ + { label: 'Wrap', value: 'wrap' }, + { label: 'Overflow', value: 'overflow' }, + { label: 'Clip', value: 'clip' }, + ], + value$: new BehaviorSubject('wrap'), + }, + }); + const rendered = renderStylePanel({ groups, onExecute }); + root = rendered.root; + container = rendered.container; + + expect(getButton(rendered.container, 'Left').getAttribute('aria-pressed')).toBe('true'); + expect(getButton(rendered.container, 'Bottom').getAttribute('aria-pressed')).toBe('true'); + + act(() => { + horizontalValue$.next(HorizontalAlign.CENTER); + verticalValue$.next(VerticalAlign.MIDDLE); + }); + + expect(getButton(rendered.container, 'Left').getAttribute('aria-pressed')).toBe('false'); + expect(getButton(rendered.container, 'Center').getAttribute('aria-pressed')).toBe('true'); + expect(getButton(rendered.container, 'Bottom').getAttribute('aria-pressed')).toBe('false'); + expect(getButton(rendered.container, 'Middle').getAttribute('aria-pressed')).toBe('true'); + + const commands = [ + ['Left', SetHorizontalTextAlignCommand.id, HorizontalAlign.LEFT], + ['Center', SetHorizontalTextAlignCommand.id, HorizontalAlign.CENTER], + ['Right', SetHorizontalTextAlignCommand.id, HorizontalAlign.RIGHT], + ['Top', SetVerticalTextAlignCommand.id, VerticalAlign.TOP], + ['Middle', SetVerticalTextAlignCommand.id, VerticalAlign.MIDDLE], + ['Bottom', SetVerticalTextAlignCommand.id, VerticalAlign.BOTTOM], + ['Wrap', SetTextWrapCommand.id, 'wrap'], + ['Overflow', SetTextWrapCommand.id, 'overflow'], + ['Clip', SetTextWrapCommand.id, 'clip'], + ] as const; + commands.forEach(([label]) => clickButton(rendered.container, label)); + expect(onExecute.mock.calls.map(([command]) => command)).toEqual(commands.map(([, id, value]) => ({ id, value }))); + }); + + it('supports reset, preset selection, and custom-color navigation', () => { + const onExecute = vi.fn(); + const onOpenView = vi.fn(); + const onUseColor = vi.fn(); + const rendered = renderStylePanel({ + currentView: { + kind: 'color', + target: 'text', + title: 'Text color', + item: { + id: SetRangeTextColorCommand.id, + type: MenuItemType.BUTTON, + value$: new BehaviorSubject('#000000'), + }, + }, + onExecute, + onOpenView, + onUseColor, + }); + root = rendered.root; + container = rendered.container; + + clickButton(rendered.container, 'sheets-ui.toolbar.resetColor'); + expect(onExecute).toHaveBeenCalledWith({ id: ResetRangeTextColorCommand.id }); + + clickButton(rendered.container, '#3F83F8'); + expect(onExecute).toHaveBeenCalledWith({ id: SetRangeTextColorCommand.id, value: '#3F83F8' }); + expect(onUseColor).toHaveBeenCalledWith('#3F83F8'); + + clickButton(rendered.container, 'sheets-ui.mobile.customColor'); + expect(onOpenView).toHaveBeenCalledWith(expect.objectContaining({ + kind: 'custom-color', + target: 'text', + value: '#3F83F8', + })); + }); + + it('lays out rotation, merge, and number-format controls for mobile', () => { + const onExecute = vi.fn(); + const onOpenView = vi.fn(); + const numfmtConfig = { + kind: 'number-format' as const, + title: 'Number format', + commandId: 'set-pattern', + detailTitle: 'More formats', + customTitle: 'Custom format', + quickOptions: [ + { label: 'Percent', commandId: 'percent' }, + { label: 'Currency', commandId: 'currency' }, + { label: 'Date', commandId: 'set-pattern', value: 'yyyy-mm-dd' }, + { label: 'Text', commandId: 'set-pattern', value: '@' }, + ], + decimalOptions: [ + { label: 'Decrease decimal', commandId: 'subtract-decimal' }, + { label: 'Increase decimal', commandId: 'add-decimal' }, + ], + detailOptions: [{ label: 'General', value: null }, { label: 'Custom format', custom: true }], + customPatterns: ['0.00'], + }; + const mergeChildren: Array<[string, string]> = [ + [AddWorksheetMergeAllCommand.id, 'Merge all'], + [AddWorksheetMergeVerticalCommand.id, 'Merge vertical'], + [AddWorksheetMergeHorizontalCommand.id, 'Merge horizontal'], + [RemoveWorksheetMergeCommand.id, 'Unmerge'], + ]; + const numberFormatItem: MobileNumberFormatItem = { + id: 'more-format', + type: MenuItemType.SELECTOR, + selections: [], + value$: new BehaviorSubject('General'), + mobileStyle: numfmtConfig, + }; + const groups: IMenuSchema[] = [{ + key: 'layout', + order: 0, + children: [ + { + key: SetShrinkToFitCommand.id, + order: 0, + item: { id: SetShrinkToFitCommand.id, type: MenuItemType.BUTTON, title: 'Shrink to fit' }, + }, + { + key: SetTextRotationCommand.id, + order: 1, + item: { + id: SetTextRotationCommand.id, + type: MenuItemType.SELECTOR, + title: 'Text rotation', + tooltip: 'Text rotation', + selections: [{ label: 'No rotation', value: 0 }], + value$: new BehaviorSubject(0), + }, + }, + { + key: AddWorksheetMergeCommand.id, + order: 2, + item: { id: AddWorksheetMergeCommand.id, type: MenuItemType.SUBITEMS, tooltip: 'Merge cells' }, + children: mergeChildren.map(([id, title]) => ({ + key: id, + order: 0, + item: { id, type: MenuItemType.BUTTON, title }, + })), + }, + ], + }, { + key: 'number', + order: 1, + children: [ + { + key: 'more-format', + order: 0, + item: numberFormatItem, + }, + ...['percent', 'currency', 'subtract-decimal', 'add-decimal'].map((id) => ({ + key: id, + order: 1, + item: { id, type: MenuItemType.BUTTON, title: id }, + })), + ], + }]; + const rendered = renderStylePanel({ groups, onExecute, onOpenView }); + root = rendered.root; + container = rendered.container; + + expect(rendered.container.textContent).not.toContain('Shrink to fit'); + const rotationButton = Array.from(rendered.container.querySelectorAll('button')).find((element) => + element.textContent?.includes('Text rotation')); + expect(rotationButton?.textContent).toContain('No rotation'); + mergeChildren.forEach(([, title]) => expect(getButton(rendered.container, title)).toBeTruthy()); + ['Percent', 'Currency', 'Date', 'Text', 'Decrease decimal', 'Increase decimal'] + .forEach((title) => expect(getButton(rendered.container, title)).toBeTruthy()); + const moreFormatsButton = Array.from(rendered.container.querySelectorAll('button')).find((element) => + element.textContent?.includes('More formats')); + expect(moreFormatsButton).toBeTruthy(); + + const expectedCommands: Array<[string, { id: string; value?: string }]> = [ + ['Percent', { id: 'percent' }], + ['Currency', { id: 'currency' }], + ['Date', { id: 'set-pattern', value: 'yyyy-mm-dd' }], + ['Text', { id: 'set-pattern', value: '@' }], + ['Decrease decimal', { id: 'subtract-decimal' }], + ['Increase decimal', { id: 'add-decimal' }], + ...mergeChildren.map(([id, title]): [string, { id: string }] => [title, { id }]), + ]; + expectedCommands.forEach(([label]) => clickButton(rendered.container, label)); + expect(onExecute.mock.calls.map(([command]) => command)).toEqual(expectedCommands.map(([, command]) => command)); + act(() => moreFormatsButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }))); + expect(onOpenView).toHaveBeenCalledWith(expect.objectContaining({ kind: 'number-format' })); + }); + + it('executes every border placement, border style, and border color control', () => { + const onExecute = vi.fn(); + const onOpenView = vi.fn(); + const onUseColor = vi.fn(); + const item: IDisplayMenuItem = { + id: SetBorderBasicCommand.id, + type: MenuItemType.BUTTON, + }; + let rendered = renderStylePanel({ + currentView: { kind: 'border', title: 'Border', item }, + onExecute, + onOpenView, + }); + root = rendered.root; + container = rendered.container; + const borderValue = { + type: BorderType.ALL, + color: rendered.injector.get(ThemeService).getColorFromTheme('gray.900'), + style: BorderStyleTypes.THIN, + activeBorderType: false, + }; + + BORDER_LINE_CHILDREN.forEach((option) => clickButton(rendered.container, option.label)); + expect(onExecute.mock.calls.map(([command]) => command)).toEqual(BORDER_LINE_CHILDREN.map((option) => ({ + id: SetBorderBasicCommand.id, + value: { ...borderValue, type: option.value }, + }))); + clickButton(rendered.container, 'sheets-ui.mobile.borderColor'); + clickButton(rendered.container, 'sheets-ui.mobile.borderStyle'); + expect(onOpenView).toHaveBeenCalledWith(expect.objectContaining({ kind: 'border-color' })); + expect(onOpenView).toHaveBeenCalledWith(expect.objectContaining({ kind: 'border-style' })); + + act(() => root?.unmount()); + rendered.container.remove(); + onExecute.mockClear(); + rendered = renderStylePanel({ currentView: { kind: 'border-style', title: 'Border style', item }, onExecute }); + root = rendered.root; + container = rendered.container; + BORDER_SIZE_CHILDREN.forEach((option) => clickButton(rendered.container, String(option.value))); + expect(onExecute.mock.calls.map(([command]) => command)).toEqual(BORDER_SIZE_CHILDREN.map((option) => ({ + id: SetBorderBasicCommand.id, + value: { ...borderValue, style: option.value }, + }))); + + act(() => root?.unmount()); + rendered.container.remove(); + onExecute.mockClear(); + rendered = renderStylePanel({ + currentView: { kind: 'border-color', title: 'Border color', item }, + onExecute, + onOpenView, + onUseColor, + }); + root = rendered.root; + container = rendered.container; + clickButton(rendered.container, 'sheets-ui.toolbar.resetColor'); + clickButton(rendered.container, '#3F83F8'); + clickButton(rendered.container, 'sheets-ui.mobile.customColor'); + expect(onExecute).toHaveBeenNthCalledWith(1, { + id: SetBorderBasicCommand.id, + value: borderValue, + }); + expect(onExecute).toHaveBeenNthCalledWith(2, { + id: SetBorderBasicCommand.id, + value: { ...borderValue, color: '#3F83F8' }, + }); + expect(onUseColor).toHaveBeenCalledWith('#3F83F8'); + expect(onOpenView).toHaveBeenCalledWith(expect.objectContaining({ kind: 'custom-color', target: 'border' })); + }); + + it('executes background color reset and preset controls', () => { + const onExecute = vi.fn(); + const rendered = renderStylePanel({ + currentView: { + kind: 'color', + target: 'background', + title: 'Background color', + item: { + id: SetBackgroundColorCommand.id, + type: MenuItemType.BUTTON, + value$: new BehaviorSubject('#FFFFFF'), + }, + }, + onExecute, + }); + root = rendered.root; + container = rendered.container; + + clickButton(rendered.container, 'sheets-ui.toolbar.resetColor'); + clickButton(rendered.container, '#3F83F8'); + expect(onExecute.mock.calls.map(([command]) => command)).toEqual([ + { id: ResetBackgroundColorCommand.id }, + { id: SetBackgroundColorCommand.id, value: '#3F83F8' }, + ]); + }); + + it('renders detailed number formats and keeps custom format in a third-level view', () => { + const onExecute = vi.fn(); + const onOpenView = vi.fn(); + const config = { + kind: 'number-format' as const, + title: 'Number format', + commandId: 'set-pattern', + detailTitle: 'More formats', + customTitle: 'Custom format', + quickOptions: [], + decimalOptions: [], + detailOptions: [ + { label: 'General', value: null }, + { divider: true }, + { label: 'Date', value: 'yyyy-mm-dd' }, + { label: 'Custom format', custom: true }, + ], + customPatterns: ['0.00', 'yyyy-mm-dd'], + }; + const item: MobileNumberFormatItem = { + id: 'more-format', + type: MenuItemType.SELECTOR, + selections: [], + value$: new BehaviorSubject('Date'), + mobileStyle: config, + }; + const rendered = renderStylePanel({ + currentView: { kind: 'number-format', title: 'More formats', item, config }, + onExecute, + onOpenView, + }); + root = rendered.root; + container = rendered.container; + + expect(getButton(rendered.container, 'Date').getAttribute('aria-pressed')).toBe('true'); + clickButton(rendered.container, 'General'); + expect(onExecute).toHaveBeenCalledWith({ id: 'set-pattern', value: null }); + clickButton(rendered.container, 'Custom format'); + expect(onOpenView).toHaveBeenCalledWith(expect.objectContaining({ kind: 'custom-number-format' })); + }); + + it('applies a custom number format from the third-level view', () => { + const onExecute = vi.fn(); + const onBack = vi.fn(); + const config = { + kind: 'number-format' as const, + title: 'Number format', + commandId: 'set-pattern', + detailTitle: 'More formats', + customTitle: 'Custom format', + quickOptions: [], + decimalOptions: [], + detailOptions: [], + customPatterns: ['0.00', 'yyyy-mm-dd'], + }; + const item: MobileNumberFormatItem = { id: 'more-format', type: MenuItemType.SELECTOR, selections: [] }; + const rendered = renderStylePanel({ + currentView: { kind: 'custom-number-format', title: 'Custom format', item, config }, + onExecute, + onBack, + }); + root = rendered.root; + container = rendered.container; + + clickButton(rendered.container, '0.00'); + clickButton(rendered.container, 'sheets-ui.mobile.confirm'); + expect(onExecute).toHaveBeenCalledWith({ id: 'set-pattern', value: '0.00' }); + expect(onBack).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/sheets-ui/src/views/mobile/formula-bar/MobileFormulaBar.tsx b/packages/sheets-ui/src/views/mobile/formula-bar/MobileFormulaBar.tsx new file mode 100644 index 0000000000..7f166334df --- /dev/null +++ b/packages/sheets-ui/src/views/mobile/formula-bar/MobileFormulaBar.tsx @@ -0,0 +1,113 @@ +/** + * 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 { + DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY, + FOCUSING_FX_BAR_EDITOR, + IContextService, +} from '@univerjs/core'; +import { clsx } from '@univerjs/design'; +import { IEditorService } from '@univerjs/docs-ui'; +import { useDependency, useObservable } from '@univerjs/ui'; +import { useEffect, useState } from 'react'; +import { map } from 'rxjs'; +import { MOBILE_FX_EDITOR_EXPANDED } from '../../../consts/mobile-context'; +import { IEditorBridgeService } from '../../../services/editor-bridge.service'; +import { FormulaBar } from '../../formula-bar/FormulaBar'; + +export function MobileFormulaBar() { + const editorBridgeService = useDependency(IEditorBridgeService); + const contextService = useDependency(IContextService); + const editorService = useDependency(IEditorService); + const visible = useObservable( + () => editorBridgeService.visible$.pipe(map((state) => state.visible)), + false, + false, + [editorBridgeService] + ); + + useEffect(() => { + if (!visible) return undefined; + + contextService.setContextValue(FOCUSING_FX_BAR_EDITOR, true); + let cursorFrame: number | undefined; + const contentFrame = requestAnimationFrame(() => { + // The mobile formula editor is mounted lazily. Replay the selected cell after mount so + // its current value is not lost when the earlier selection sync ran before this editor existed. + editorBridgeService.refreshEditCellState(); + editorService.focus(DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY); + cursorFrame = requestAnimationFrame(() => { + const editor = editorService.getEditor(DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY); + const end = Math.max(0, (editor?.getDocumentData().body?.dataStream.length ?? 2) - 2); + editor?.setSelectionRanges([{ startOffset: end, endOffset: end }], false); + }); + }); + return () => { + cancelAnimationFrame(contentFrame); + if (cursorFrame !== undefined) { + cancelAnimationFrame(cursorFrame); + } + }; + }, [contextService, editorBridgeService, editorService, visible]); + + if (!visible) { + return null; + } + + return ; +} + +function MobileFormulaBarEditor() { + const contextService = useDependency(IContextService); + const [expanded, setExpanded] = useState(false); + + useEffect(() => { + return () => contextService.setContextValue(MOBILE_FX_EDITOR_EXPANDED, false); + }, [contextService]); + + function handleExpandedChange(nextExpanded: boolean) { + contextService.setContextValue(MOBILE_FX_EDITOR_EXPANDED, nextExpanded); + setExpanded(nextExpanded); + } + + return ( +
    + +
    + ); +} diff --git a/packages/sheets-ui/src/views/mobile/formula-bar/MobileFormulaBarControls.tsx b/packages/sheets-ui/src/views/mobile/formula-bar/MobileFormulaBarControls.tsx new file mode 100644 index 0000000000..5cc26725a9 --- /dev/null +++ b/packages/sheets-ui/src/views/mobile/formula-bar/MobileFormulaBarControls.tsx @@ -0,0 +1,152 @@ +/** + * 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 { clsx, resetButtonClassName } from '@univerjs/design'; +import { MobileRichTextToolbar } from '@univerjs/docs-ui'; +import { CheckMarkIcon, CloseIcon, DownIcon, FxIcon } from '@univerjs/icons'; +import { MOBILE_FORMULA_OPERATOR_BAR_HEIGHT } from '../../../consts/mobile-context'; + +const FORMULA_OPERATORS = [ + { label: '+', value: '+' }, + { label: '−', value: '-' }, + { label: '×', value: '*' }, + { label: '÷', value: '/' }, + { label: '=', value: '=' }, + { label: '>', value: '>' }, + { label: '<', value: '<' }, + { label: ',', value: ',' }, + { label: ':', value: ':' }, + { label: '(', value: '(' }, + { label: ')', value: ')' }, + { label: '&', value: '&' }, + { label: '!', value: '!' }, +] as const; + +interface IMobileFormulaBarOverlayProps { + expanded: boolean; + formulaActive: boolean; + operatorsVisible: boolean; + editorId: string; + onOperator: (value: string) => void; +} + +interface IMobileFormulaBarActionProps { + expanded: boolean; + cancelLabel: string; + confirmLabel: string; + formulaLabel: string; + collapseLabel: string; + onCancel: () => void; + onConfirm: () => void; + onFormula: () => void; + onCollapse: () => void; +} + +export function MobileFormulaBarOverlays(props: IMobileFormulaBarOverlayProps) { + return ( + <> + {props.operatorsVisible && ( +
    + {FORMULA_OPERATORS.map((operator) => ( + + ))} +
    + )} + {props.expanded && !props.formulaActive && ( + + )} + + ); +} + +export function MobileFormulaBarActions(props: IMobileFormulaBarActionProps) { + const actions = [ + { label: props.cancelLabel, icon: , onClick: props.onCancel, color: 'univer-text-red-600 dark:!univer-text-red-400' }, + { label: props.confirmLabel, icon: , onClick: props.onConfirm, color: 'univer-text-green-600 dark:!univer-text-green-400' }, + { label: props.formulaLabel, icon: , onClick: props.onFormula, color: 'univer-text-gray-700 dark:!univer-text-gray-100' }, + ]; + + if (props.expanded) { + actions.push({ + label: props.collapseLabel, + icon: , + onClick: props.onCollapse, + color: 'univer-text-gray-700 dark:!univer-text-gray-100', + }); + } + + return ( +
    + {actions.map((action) => ( + + ))} +
    + ); +} diff --git a/packages/sheets-ui/src/views/mobile/sheet-bar/MobileSheetBar.tsx b/packages/sheets-ui/src/views/mobile/sheet-bar/MobileSheetBar.tsx index e3d253878a..dff92c8c7e 100644 --- a/packages/sheets-ui/src/views/mobile/sheet-bar/MobileSheetBar.tsx +++ b/packages/sheets-ui/src/views/mobile/sheet-bar/MobileSheetBar.tsx @@ -15,21 +15,27 @@ */ import type { ICommandInfo, Workbook } from '@univerjs/core'; -import type { ISetWorksheetActiveOperationParams } from '@univerjs/sheets'; +import type { MouseEvent } from 'react'; import type { LocaleKey } from '../../../locale/types'; import type { IBaseSheetBarProps } from '../../sheet-bar/sheet-bar-tabs/SheetBarItem'; -import { ICommandService, LocaleService } from '@univerjs/core'; -import { borderRightClassName, clsx, scrollbarClassName } from '@univerjs/design'; +import { ICommandService, LocaleService, nameCharacterCheck } from '@univerjs/core'; +import { borderBottomClassName, borderLeftClassName, borderRightClassName, clsx, Dialog, Input, resetButtonClassName, scrollbarClassName } from '@univerjs/design'; +import { IncreaseIcon, MoreDownIcon } from '@univerjs/icons'; import { + InsertSheetCommand, InsertSheetMutation, RemoveSheetMutation, + SetTabColorMutation, SetWorksheetActiveOperation, SetWorksheetHideMutation, + SetWorksheetNameCommand, SetWorksheetNameMutation, SetWorksheetOrderMutation, + SetWorksheetShowCommand, } from '@univerjs/sheets'; -import { useDependency } from '@univerjs/ui'; +import { ContextMenuPosition, IContextMenuService, useDependency } from '@univerjs/ui'; import { useCallback, useEffect, useRef, useState } from 'react'; +import { ISheetBarService } from '../../../services/sheet-bar/sheet-bar.service'; import { useActiveWorkbook } from '../../hook'; export function MobileSheetBar() { @@ -46,11 +52,17 @@ function MobileSheetBarImpl(props: { workbook: Workbook }) { const [sheetList, setSheetList] = useState([]); const [activeKey, setActiveKey] = useState(''); + const [renameSheetId, setRenameSheetId] = useState(null); + const [renameValue, setRenameValue] = useState(''); + const [renameError, setRenameError] = useState(''); + const [unhideVisible, setUnhideVisible] = useState(false); const tabMapRef = useRef>(new Map()); const containerRef = useRef(null); const commandService = useDependency(ICommandService); + const contextMenuService = useDependency(IContextMenuService); const localeService = useDependency(LocaleService); + const sheetBarService = useDependency(ISheetBarService); const updateSheetItems = useCallback(() => { const currentSubUnitId = workbook.getActiveSheet()!.getSheetId(); @@ -87,16 +99,73 @@ function MobileSheetBarImpl(props: { workbook: Workbook }) { useEffect(() => updateSheetItems(), [updateSheetItems]); + useEffect(() => { + const subscription = sheetBarService.renameId$.subscribe((sheetId) => { + const sheet = workbook.getSheetBySheetId(sheetId); + if (!sheet) return; + + setRenameSheetId(sheetId); + setRenameValue(sheet.getName()); + setRenameError(''); + }); + + return () => subscription.unsubscribe(); + }, [sheetBarService, workbook]); + + useEffect(() => { + const disposable = sheetBarService.registerSheetBarMenuHandler({ + handleSheetBarMenu: () => setUnhideVisible(true), + }); + return () => disposable.dispose(); + }, [sheetBarService]); + const handleClick = useCallback((sheetId: string) => { commandService.executeCommand(SetWorksheetActiveOperation.id, { unitId: workbook.getUnitId(), subUnitId: sheetId, - } as ISetWorksheetActiveOperationParams); - }, [workbook]); + }); + }, [commandService, workbook]); + + const openSheetMenu = useCallback((event: MouseEvent) => { + contextMenuService.triggerContextMenu(event, ContextMenuPosition.FOOTER_TABS, { + unitId: workbook.getUnitId(), + subUnitId: workbook.getActiveSheet()?.getSheetId(), + }); + }, [contextMenuService, workbook]); + + const addSheet = useCallback(() => { + commandService.executeCommand(InsertSheetCommand.id, { unitId: workbook.getUnitId() }); + }, [commandService, workbook]); + + const confirmRename = useCallback(() => { + if (!renameSheetId) return; + + const name = renameValue.trim(); + if (!name) { + setRenameError(localeService.t('sheets-ui.sheetConfig.sheetNameCannotIsEmptyError')); + return; + } + if (!nameCharacterCheck(name)) { + setRenameError(localeService.t('sheets-ui.sheetConfig.sheetNameSpecCharError')); + return; + } + if (workbook.getSheets().some((sheet) => sheet.getSheetId() !== renameSheetId && sheet.getName() === name)) { + setRenameError(localeService.t('sheets-ui.sheetConfig.sheetNameAlreadyExistsError')); + return; + } + + commandService.executeCommand(SetWorksheetNameCommand.id, { + unitId: workbook.getUnitId(), + subUnitId: renameSheetId, + name, + }); + setRenameSheetId(null); + }, [commandService, localeService, renameSheetId, renameValue, workbook]); useEffect(() => { const disposable = commandService.onCommandExecuted((commandInfo: ICommandInfo) => { switch (commandInfo.id) { + case SetTabColorMutation.id: case SetWorksheetHideMutation.id: case RemoveSheetMutation.id: case SetWorksheetNameMutation.id: @@ -111,65 +180,176 @@ function MobileSheetBarImpl(props: { workbook: Workbook }) { }); return () => disposable.dispose(); - }, [updateSheetItems]); + }, [commandService, updateSheetItems]); return ( -
    ('sheets-ui.sheetConfig.sheetTabs')} - > -
    - {sheetList.map((sheet) => ( -
    { - tabMapRef.current.set(sheet.sheetId!, element); - }} - className={clsx(` - univer-relative univer-box-border univer-flex univer-h-full univer-max-w-36 univer-shrink-0 - univer-cursor-pointer univer-select-none univer-items-center univer-justify-center - univer-truncate univer-px-1 univer-py-0.5 univer-text-sm univer-font-medium univer-leading-6 - univer-transition-all - `, borderRightClassName, { - 'univer-bg-gray-0 univer-text-blue-600 dark:!univer-bg-gray-700 dark:!univer-text-blue-400': sheet.sheetId === activeKey, - 'univer-text-gray-600 hover:univer-bg-gray-50 active:univer-bg-gray-100 dark:!univer-text-gray-300 dark:hover:!univer-bg-gray-700': sheet.sheetId !== activeKey, - })} - key={sheet.sheetId} - role="tab" - aria-selected={sheet.sheetId === activeKey} - aria-controls={`sheet-${sheet.sheetId}`} - tabIndex={sheet.sheetId === activeKey ? 0 : -1} - onClick={() => handleClick(sheet.sheetId!)} - > - {/* Sheet Label */} - {sheet.label} - - {/* Active Indicator */} - {sheet.sheetId === activeKey && ( + <> +
    +
    ('sheets-ui.sheetConfig.sheetTabs')} + > +
    + {sheetList.map((sheet) => (
    { + tabMapRef.current.set(sheet.sheetId!, element); + }} className={clsx(` - univer-absolute univer-left-0 univer-right-0 univer-top-0 univer-h-1 - univer-bg-blue-600 univer-transition-all - dark:!univer-bg-blue-400 - ` + univer-relative univer-box-border univer-flex univer-h-full univer-max-w-36 + univer-shrink-0 univer-cursor-pointer univer-select-none univer-items-center + univer-justify-center univer-truncate univer-px-1 univer-py-0.5 univer-text-sm + univer-font-medium univer-leading-6 univer-transition-all + `, borderRightClassName, { + 'univer-bg-gray-0 univer-text-primary-600 dark:!univer-bg-gray-700 dark:!univer-text-primary-400': sheet.sheetId === activeKey, + 'univer-text-gray-600 hover:univer-bg-gray-50 active:univer-bg-gray-100 dark:!univer-text-gray-300 dark:hover:!univer-bg-gray-700': sheet.sheetId !== activeKey, + })} + key={sheet.sheetId} + role="tab" + aria-selected={sheet.sheetId === activeKey} + aria-controls={`sheet-${sheet.sheetId}`} + tabIndex={sheet.sheetId === activeKey ? 0 : -1} + onClick={() => handleClick(sheet.sheetId!)} + > + {/* Sheet Label */} + {sheet.label} + {sheet.sheetId === activeKey && ( + )} - style={sheet.color - ? { backgroundColor: sheet.color } - : undefined} - aria-hidden="true" - /> - )} + + {/* Active Indicator */} + {sheet.sheetId === activeKey && ( + + ))}
    - ))} +
    +
    -
    + ('sheets-ui.sheetConfig.rename')} + showCancel + showOk + onCancel={() => setRenameSheetId(null)} + onOk={confirmRename} + onOpenChange={(open) => { + if (!open) setRenameSheetId(null); + }} + > +
    + { + setRenameValue(value); + setRenameError(''); + }} + onKeyDown={(event) => { + if (event.key === 'Enter') confirmRename(); + }} + /> + {renameError && ( +
    + {renameError} +
    + )} +
    +
    + ('sheets-ui.sheetConfig.unhide')} + footer={null} + onOpenChange={setUnhideVisible} + > +
    + {workbook.getHiddenWorksheets().map((sheetId) => ( + + ))} +
    +
    + ); } diff --git a/packages/sheets-ui/src/views/permission/SheetPermissionDialog.tsx b/packages/sheets-ui/src/views/permission/SheetPermissionDialog.tsx index 802ecc55e8..eb3d1e589f 100644 --- a/packages/sheets-ui/src/views/permission/SheetPermissionDialog.tsx +++ b/packages/sheets-ui/src/views/permission/SheetPermissionDialog.tsx @@ -25,7 +25,7 @@ import { LocaleService, UniverInstanceType, } from '@univerjs/core'; -import { borderTopClassName, Button, clsx, Switch } from '@univerjs/design'; +import { ActionRow, borderTopClassName, Button, clsx, Switch } from '@univerjs/design'; import { ObjectScope, UnitObject, UnitRole } from '@univerjs/protocol'; import { getAllWorksheetPermissionPoint, @@ -230,7 +230,7 @@ export const SheetPermissionDialog = () => { ); })} -
    { > {localeService.t('sheets-ui.permission.button.confirm')} -
    +
    ); diff --git a/packages/sheets-ui/src/views/permission/SheetPermissionPanelList.tsx b/packages/sheets-ui/src/views/permission/SheetPermissionPanelList.tsx index 0a7c982ba7..5b88ecf111 100644 --- a/packages/sheets-ui/src/views/permission/SheetPermissionPanelList.tsx +++ b/packages/sheets-ui/src/views/permission/SheetPermissionPanelList.tsx @@ -29,7 +29,7 @@ import { UniverInstanceType, UserManagerService, } from '@univerjs/core'; -import { Avatar, borderClassName, Button, clsx, Tooltip } from '@univerjs/design'; +import { ActionRow, Avatar, borderClassName, Button, clsx, Tooltip } from '@univerjs/design'; import { serializeRange } from '@univerjs/engine-formula'; import { DeleteIcon, WriteIcon } from '@univerjs/icons'; import { UnitAction, UnitObject } from '@univerjs/protocol'; @@ -405,7 +405,7 @@ function SheetPermissionPanelListContent() { )} {hasSetProtectPermission && ( -
    +
    {localeService.t('sheets-ui.permission.button.addNewPermission')} -
    + )}
    ); diff --git a/packages/sheets-ui/src/views/permission/SheetPermissionUserDialog.tsx b/packages/sheets-ui/src/views/permission/SheetPermissionUserDialog.tsx index a49ca41fc0..885d93097c 100644 --- a/packages/sheets-ui/src/views/permission/SheetPermissionUserDialog.tsx +++ b/packages/sheets-ui/src/views/permission/SheetPermissionUserDialog.tsx @@ -17,7 +17,7 @@ import type { ICollaborator } from '@univerjs/protocol'; import type { LocaleKey } from '../../locale/types'; import { IAuthzIoService, LocaleService } from '@univerjs/core'; -import { Avatar, Button, clsx, Input, scrollbarClassName } from '@univerjs/design'; +import { ActionRow, Avatar, Button, clsx, Input, scrollbarClassName } from '@univerjs/design'; import { CheckMarkIcon } from '@univerjs/icons'; import { UnitRole } from '@univerjs/protocol'; import { IDialogService, useDependency, useObservable } from '@univerjs/ui'; @@ -120,7 +120,9 @@ export const SheetPermissionUserDialog = () => { )}
    -
    + -
    +
    ); }; diff --git a/packages/sheets-ui/src/views/permission/panel-detail/PermissionDetailFooterPart.tsx b/packages/sheets-ui/src/views/permission/panel-detail/PermissionDetailFooterPart.tsx index f2a5a81eec..57d0a035d9 100644 --- a/packages/sheets-ui/src/views/permission/panel-detail/PermissionDetailFooterPart.tsx +++ b/packages/sheets-ui/src/views/permission/panel-detail/PermissionDetailFooterPart.tsx @@ -25,7 +25,7 @@ import { LocaleService, UniverInstanceType, } from '@univerjs/core'; -import { Button } from '@univerjs/design'; +import { ActionRow, Button } from '@univerjs/design'; import { ObjectScope, UnitAction, UnitObject, UnitRole } from '@univerjs/protocol'; import { AddRangeProtectionCommand, @@ -71,7 +71,9 @@ export const PermissionDetailFooterPart = (props: IPermissionDetailFooterPartPro const subUnitId = worksheet.getSheetId(); return ( -
    + -
    + ); }; diff --git a/packages/sheets/src/commands/commands/utils/selection-utils.ts b/packages/sheets/src/commands/commands/utils/selection-utils.ts index c548eebf85..f0cffd1e28 100644 --- a/packages/sheets/src/commands/commands/utils/selection-utils.ts +++ b/packages/sheets/src/commands/commands/utils/selection-utils.ts @@ -137,7 +137,7 @@ export function setEndForRange(range: IRange, rowCount: number, columnCount: num * @param range * @param worksheet */ -export function getPrimaryForRange(range: IRange, worksheet: Worksheet): ISelectionCell { +export function getPrimaryForRange(range: IRange, worksheet: Pick): ISelectionCell { const startRow = Number.isNaN(range.startRow) ? 0 : range.startRow; const startColumn = Number.isNaN(range.startColumn) ? 0 : range.startColumn; const mergedRange = worksheet.getMergedCell(startRow, startColumn); @@ -172,7 +172,7 @@ export interface IInterval { /** * Calculate the real length of the intervals * @param intervalsObject - * @returns + * @returns The combined length covered by all intervals. */ export function calculateTotalLength(intervalsObject: IInterval): number { const points: number[] = []; diff --git a/packages/sheets/src/commands/operations/__tests__/selection.operation.spec.ts b/packages/sheets/src/commands/operations/__tests__/selection.operation.spec.ts index 5695cb17b4..3ab1701e4e 100644 --- a/packages/sheets/src/commands/operations/__tests__/selection.operation.spec.ts +++ b/packages/sheets/src/commands/operations/__tests__/selection.operation.spec.ts @@ -95,6 +95,25 @@ describe('selection.operation', () => { expect(refSelectionService.setSelections).toHaveBeenCalledWith('unit-1', 'sheet-1', [], undefined); }); + it('SetSelectionsOperation can target the current sheet selection while in ref mode', () => { + const sheetSelectionService = { setSelections: vi.fn() }; + const refSelectionService = { setSelections: vi.fn() }; + const accessor = createAccessor(new Map([ + [IContextService, { getContextValue: vi.fn(() => true) }], + [SheetsSelectionsService, sheetSelectionService], + [IRefSelectionsService, refSelectionService], + ])); + + expect(SetSelectionsOperation.handler(accessor, { + unitId: 'unit-1', + subUnitId: 'sheet-1', + selections: [], + fromCurrentSelection: true, + })).toBe(true); + expect(sheetSelectionService.setSelections).toHaveBeenCalledWith('unit-1', 'sheet-1', [], undefined); + expect(refSelectionService.setSelections).not.toHaveBeenCalled(); + }); + it('SelectRangeCommand returns false without params or target', () => { const commandService = { syncExecuteCommand: vi.fn(), diff --git a/packages/sheets/src/commands/operations/selection.operation.ts b/packages/sheets/src/commands/operations/selection.operation.ts index 66e575f8fb..da5cc441c1 100644 --- a/packages/sheets/src/commands/operations/selection.operation.ts +++ b/packages/sheets/src/commands/operations/selection.operation.ts @@ -31,6 +31,7 @@ export interface ISetSelectionsOperationParams { /** If should scroll to the selected range. */ reveal?: boolean; extra?: string; + fromCurrentSelection?: boolean; } /** @@ -42,8 +43,8 @@ export const SetSelectionsOperation: IOperation = handler: (accessor, params) => { if (!params) return false; - const { selections, type, unitId, subUnitId } = params; - const selectionManagerService = getSelectionsService(accessor); + const { selections, type, unitId, subUnitId, fromCurrentSelection } = params; + const selectionManagerService = getSelectionsService(accessor, fromCurrentSelection); // Must update selections array ref. // See https://github.com/dream-num/univer/issues/2199 diff --git a/packages/thread-comment-ui/src/index.ts b/packages/thread-comment-ui/src/index.ts index a1ceb38e73..ff5e35e831 100644 --- a/packages/thread-comment-ui/src/index.ts +++ b/packages/thread-comment-ui/src/index.ts @@ -19,6 +19,7 @@ import './global.css'; export { SetActiveCommentOperation } from './commands/operations/comment.operations'; export type { ISetActiveCommentOperationParams } from './commands/operations/comment.operations'; export type { IUniverThreadCommentUIConfig } from './config/config'; +export { UniverThreadCommentMobileUIPlugin } from './mobile-plugin'; export { UniverThreadCommentUIPlugin } from './plugin'; export { ThreadCommentCanvasOverlay } from './render-objects/thread-comment-canvas-overlay'; export type { diff --git a/packages/thread-comment-ui/src/mobile-plugin.ts b/packages/thread-comment-ui/src/mobile-plugin.ts new file mode 100644 index 0000000000..3875779652 --- /dev/null +++ b/packages/thread-comment-ui/src/mobile-plugin.ts @@ -0,0 +1,37 @@ +/** + * 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 { IUniverThreadCommentUIConfig } from './config/config'; +import { DependentOn, ICommandService, IConfigService, Inject, Injector } from '@univerjs/core'; +import { UniverDocsUIPlugin } from '@univerjs/docs-ui'; +import { UniverThreadCommentPlugin } from '@univerjs/thread-comment'; +import { UniverMobileUIPlugin } from '@univerjs/ui'; +import { defaultPluginConfig } from './config/config'; +import { UniverThreadCommentUIPlugin } from './plugin'; + +@DependentOn(UniverThreadCommentPlugin, UniverDocsUIPlugin, UniverMobileUIPlugin) +export class UniverThreadCommentMobileUIPlugin extends UniverThreadCommentUIPlugin { + static override pluginName = UniverThreadCommentUIPlugin.pluginName; + + constructor( + config: Partial = defaultPluginConfig, + @Inject(Injector) injector: Injector, + @ICommandService commandService: ICommandService, + @IConfigService configService: IConfigService + ) { + super(config, injector, commandService, configService); + } +} diff --git a/packages/thread-comment-ui/src/views/ThreadCommentEditor.tsx b/packages/thread-comment-ui/src/views/ThreadCommentEditor.tsx index 52f49a7f40..4db8c9bed9 100644 --- a/packages/thread-comment-ui/src/views/ThreadCommentEditor.tsx +++ b/packages/thread-comment-ui/src/views/ThreadCommentEditor.tsx @@ -26,7 +26,7 @@ import { Tools, UniverInstanceType, } from '@univerjs/core'; -import { Button, clsx } from '@univerjs/design'; +import { ActionRow, Button, clsx } from '@univerjs/design'; import { BreakLineCommand, DeleteLeftCommand, DeleteRightCommand, IEditorService, RichTextEditor } from '@univerjs/docs-ui'; import { KeyCode, useDependency } from '@univerjs/ui'; import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'; @@ -209,7 +209,9 @@ export const ThreadCommentEditor = forwardRef {editing ? ( -
    + -
    + ) : null}
    diff --git a/packages/thread-comment-ui/src/views/ThreadCommentPanel.tsx b/packages/thread-comment-ui/src/views/ThreadCommentPanel.tsx index 8d891b9883..682574c47a 100644 --- a/packages/thread-comment-ui/src/views/ThreadCommentPanel.tsx +++ b/packages/thread-comment-ui/src/views/ThreadCommentPanel.tsx @@ -21,7 +21,7 @@ import type { LocaleKey } from '../locale/types'; import type { ThreadCommentPanelSection } from './thread-comment-panel/util'; import type { IThreadCommentTreeProps } from './ThreadCommentTree'; import { ICommandService, LocaleService, UniverInstanceType, UserManagerService } from '@univerjs/core'; -import { Button, Select } from '@univerjs/design'; +import { ActionRow, Button, Select } from '@univerjs/design'; import { IncreaseIcon } from '@univerjs/icons'; import { ThreadCommentModel } from '@univerjs/thread-comment'; import { useDependency, useObservable } from '@univerjs/ui'; @@ -323,20 +323,22 @@ export const ThreadCommentPanel = (props: IThreadCommentPanelProps) => { {localeService.t('thread-comment-ui.panel.empty')} {isFiltering ? ( -
    + -
    + ) : !disableAdd ? ( -
    + -
    + ) : null}
    diff --git a/packages/ui/src/const.ts b/packages/ui/src/const.ts index 5846c3175f..f81f4071a2 100644 --- a/packages/ui/src/const.ts +++ b/packages/ui/src/const.ts @@ -18,3 +18,5 @@ * @ignore */ export const UNI_DISABLE_CHANGING_FOCUS_KEY = 'UNI_DISABLE_CHANGING_FOCUS'; + +export const MOBILE_UI_MODE = 'MOBILE_UI_MODE'; diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 43680d0ee0..81f5f7fbf3 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -23,7 +23,7 @@ export { getHeaderFooterMenuHiddenObservable, getMenuHiddenObservable } from './ export { mergeMenuConfigs } from './common/menu-merge-configs'; export { UI_PLUGIN_CONFIG_KEY } from './config/config'; export type { IUniverUIConfig } from './config/config'; -export { UNI_DISABLE_CHANGING_FOCUS_KEY } from './const'; +export { MOBILE_UI_MODE, UNI_DISABLE_CHANGING_FOCUS_KEY } from './const'; export { ErrorController } from './controllers/error/error.controller'; export { CopyShortcutItem, @@ -66,6 +66,7 @@ export { ContextMenuService, IContextMenuService } from './services/contextmenu/ export type { IContextMenuHandler } from './services/contextmenu/contextmenu.service'; export { DesktopDialogService } from './services/dialog/desktop-dialog.service'; export { IDialogService } from './services/dialog/dialog.service'; +export { MobileDialogService } from './services/dialog/mobile-dialog.service'; export { CanvasFloatDomPreviewService, CanvasFloatDomService } from './services/dom/canvas-dom-layer.service'; export type { ICanvasFloatDomPreview, @@ -164,6 +165,8 @@ export { FloatDomSingle } from './views/components/dom/FloatDom'; export { FloatDom } from './views/components/dom/FloatDom'; export { PrintFloatDomSingle } from './views/components/dom/Print'; export { HOVER_TRACK_HOST_CLASS_NAME, HoverTrack } from './views/components/hover-track/HoverTrack'; +export { MobileDrawer } from './views/components/mobile-drawer/MobileDrawer'; +export type { MobileDrawerSnap } from './views/components/mobile-drawer/MobileDrawer'; export { CanvasPopup, SingleCanvasPopup } from './views/components/popup/CanvasPopup'; export { RectPopup } from './views/components/popup/RectPopup'; export type { RectPopupDirection } from './views/components/popup/RectPopup'; @@ -184,5 +187,6 @@ export { FONT_SIZE_COMPONENT, FONT_SIZE_LIST, HEADING_LIST } from './views/font- export * from './views/hooks/index'; export { preventBrowserZoomInContainers } from './views/hooks/prevent-browser-zoom'; export * from './views/index'; +export { MobileMenu } from './views/menu/mobile/MobileMenu'; export { type INotificationOptions } from './views/notification/Notification'; export { ProgressBar } from './views/progress-bar/ProgressBar'; diff --git a/packages/ui/src/mobile-plugin.ts b/packages/ui/src/mobile-plugin.ts index dcd2d3cf67..52ccc67eb2 100644 --- a/packages/ui/src/mobile-plugin.ts +++ b/packages/ui/src/mobile-plugin.ts @@ -36,6 +36,7 @@ import { ComponentManager } from './common/component-manager'; import { IconManager } from './common/icon-manager'; import { ZIndexManager } from './common/z-index-manager'; import { defaultPluginConfig, UI_PLUGIN_CONFIG_KEY } from './config/config'; +import { MOBILE_UI_MODE } from './const'; import { ComponentsController } from './controllers/components.controller'; import { ErrorController } from './controllers/error/error.controller'; import { FeatureSearchController } from './controllers/feature-search/feature-search.controller'; @@ -48,8 +49,8 @@ import { BrowserClipboardService, IClipboardInterfaceService } from './services/ import { DesktopConfirmService } from './services/confirm/desktop-confirm.service'; import { ContextMenuHostService, IContextMenuHostService } from './services/contextmenu/contextmenu-host.service'; import { ContextMenuService, IContextMenuService } from './services/contextmenu/contextmenu.service'; -import { DesktopDialogService } from './services/dialog/desktop-dialog.service'; import { IDialogService } from './services/dialog/dialog.service'; +import { MobileDialogService } from './services/dialog/mobile-dialog.service'; import { CanvasFloatDomPreviewService, CanvasFloatDomService } from './services/dom/canvas-dom-layer.service'; import { FontService, IFontService } from './services/font.service'; import { DesktopGalleryService } from './services/gallery/desktop-gallery.service'; @@ -103,6 +104,7 @@ export class UniverMobileUIPlugin extends Plugin { const { menu, ...rest } = merge( { popupRootId: `univer-popup-portal-${generateRandomId(6)}`, + disableAutoFocus: true, }, defaultPluginConfig, this._config @@ -111,6 +113,7 @@ export class UniverMobileUIPlugin extends Plugin { if (rest.disableAutoFocus) { this._contextService.setContextValue(DISABLE_AUTO_FOCUS_KEY, true); } + this._contextService.setContextValue(MOBILE_UI_MODE, true); if (menu) { this._configService.setConfig('menu', menu, { merge: true }); } @@ -140,7 +143,7 @@ export class UniverMobileUIPlugin extends Plugin { [IClipboardInterfaceService, { useClass: BrowserClipboardService, lazy: true }], [INotificationService, { useClass: DesktopNotificationService, lazy: true }], [IGalleryService, { useClass: DesktopGalleryService, lazy: true }], - [IDialogService, { useClass: DesktopDialogService, lazy: true }], + [IDialogService, { useClass: MobileDialogService, lazy: true }], [IConfirmService, { useClass: DesktopConfirmService, lazy: true }], [ISidebarService, { useClass: DesktopSidebarService, lazy: true }], [IMessageService, { useClass: DesktopMessageService, lazy: true }], diff --git a/packages/ui/src/services/contextmenu/contextmenu.service.ts b/packages/ui/src/services/contextmenu/contextmenu.service.ts index 53f6dfddb2..9e6898b45d 100644 --- a/packages/ui/src/services/contextmenu/contextmenu.service.ts +++ b/packages/ui/src/services/contextmenu/contextmenu.service.ts @@ -15,17 +15,20 @@ */ import type { IDisposable } from '@univerjs/core'; -import type { IMouseEvent, IPointerEvent } from '@univerjs/engine-render'; +import type { IMouseEvent } from '@univerjs/engine-render'; import { createIdentifier, Disposable, toDisposable } from '@univerjs/core'; export interface IContextMenuTriggerContext { autoFocus?: boolean; unitId?: string; + subUnitId?: string; } +export type ContextMenuEvent = Pick; + export interface IContextMenuHandler { /** A callback to open context menu with given position and menu type. */ - handleContextMenu(event: IPointerEvent | IMouseEvent, menuType: string, context?: IContextMenuTriggerContext): void; + handleContextMenu(event: ContextMenuEvent, menuType: string, context?: IContextMenuTriggerContext): void; hideContextMenu(): void; get visible(): boolean; @@ -37,7 +40,7 @@ export interface IContextMenuService { enable(): void; disable(): void; - triggerContextMenu(event: IPointerEvent | IMouseEvent, menuType: string, context?: IContextMenuTriggerContext): void; + triggerContextMenu(event: ContextMenuEvent, menuType: string, context?: IContextMenuTriggerContext): void; hideContextMenu(): void; registerContextMenuHandler(handler: IContextMenuHandler): IDisposable; } @@ -60,7 +63,7 @@ export class ContextMenuService extends Disposable implements IContextMenuServic this.disabled = false; } - triggerContextMenu(event: IPointerEvent | IMouseEvent, menuType: string, context?: IContextMenuTriggerContext): void { + triggerContextMenu(event: ContextMenuEvent, menuType: string, context?: IContextMenuTriggerContext): void { event.stopPropagation(); if (this.disabled) return; diff --git a/packages/ui/src/services/dialog/mobile-dialog.service.ts b/packages/ui/src/services/dialog/mobile-dialog.service.ts new file mode 100644 index 0000000000..46e2f06055 --- /dev/null +++ b/packages/ui/src/services/dialog/mobile-dialog.service.ts @@ -0,0 +1,42 @@ +/** + * 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 { Inject, Injector } from '@univerjs/core'; +import { connectInjector } from '../../utils/di'; +import { MobileDialogPart } from '../../views/components/dialog-part/MobileDialogPart'; +import { BuiltInUIPart, IUIPartsService } from '../parts/parts.service'; +import { DesktopDialogService } from './desktop-dialog.service'; + +export class MobileDialogService extends DesktopDialogService { + constructor( + @Inject(Injector) injector: Injector, + @IUIPartsService uiPartsService: IUIPartsService + ) { + super(injector, uiPartsService); + } + + override close(id: string): void { + const dialog = this._dialogOptions.find((item) => item.id === id); + if (!dialog || dialog.open === false) return; + super.close(id); + } + + protected override _initUIPart(): void { + this.disposeWithMe( + this._uiPartsService.registerComponent(BuiltInUIPart.GLOBAL, () => connectInjector(MobileDialogPart, this._injector)) + ); + } +} diff --git a/packages/ui/src/utils/__tests__/storage-driver.spec.ts b/packages/ui/src/utils/__tests__/storage-driver.spec.ts index 1955e68821..e21dad2cf4 100644 --- a/packages/ui/src/utils/__tests__/storage-driver.spec.ts +++ b/packages/ui/src/utils/__tests__/storage-driver.spec.ts @@ -57,6 +57,40 @@ describe('browserStorage (LocalStorageDriver fallback)', () => { }); describe('getItem / setItem', () => { + it('should fall back to localStorage when IndexedDB cannot be opened', async () => { + const originalIndexedDB = globalThis.indexedDB; + let onerror: IDBRequest['onerror'] = null; + const request = { + error: new DOMException('IndexedDB is unavailable'), + get onerror() { + return onerror; + }, + set onerror(value: IDBRequest['onerror']) { + onerror = value; + }, + onsuccess: null, + onupgradeneeded: null, + }; + + vi.stubGlobal('indexedDB', { + open: () => { + queueMicrotask(() => { + if (onerror) { + Reflect.apply(onerror, request, [new Event('error')]); + } + }); + return request; + }, + }); + vi.resetModules(); + + const { browserStorage: fallbackStorage } = await import('../storage-driver'); + await fallbackStorage.setItem('fallback', 'local'); + + expect(await fallbackStorage.getItem('fallback')).toBe('local'); + vi.stubGlobal('indexedDB', originalIndexedDB); + }); + it('should store and retrieve a string', async () => { await browserStorage.setItem('str', 'hello'); const value = await browserStorage.getItem('str'); diff --git a/packages/ui/src/utils/storage-driver.ts b/packages/ui/src/utils/storage-driver.ts index 611a6389b0..d2a3d4268d 100644 --- a/packages/ui/src/utils/storage-driver.ts +++ b/packages/ui/src/utils/storage-driver.ts @@ -206,10 +206,63 @@ class LocalStorageDriver implements IStorageDriver { } } +class FallbackStorageDriver implements IStorageDriver { + private _fallbackEnabled = false; + + constructor( + private readonly _primary: IStorageDriver, + private readonly _fallback: IStorageDriver + ) {} + + private async _run(primary: () => Promise, fallback: () => Promise): Promise { + if (this._fallbackEnabled) { + return fallback(); + } + + try { + return await primary(); + } catch { + this._fallbackEnabled = true; + return fallback(); + } + } + + getItem(key: string): Promise { + return this._run(() => this._primary.getItem(key), () => this._fallback.getItem(key)); + } + + setItem(key: string, value: T): Promise { + return this._run(() => this._primary.setItem(key, value), () => this._fallback.setItem(key, value)); + } + + removeItem(key: string): Promise { + return this._run(() => this._primary.removeItem(key), () => this._fallback.removeItem(key)); + } + + clear(): Promise { + return this._run(() => this._primary.clear(), () => this._fallback.clear()); + } + + key(index: number): Promise { + return this._run(() => this._primary.key(index), () => this._fallback.key(index)); + } + + keys(): Promise { + return this._run(() => this._primary.keys(), () => this._fallback.keys()); + } + + iterate(iteratee: (value: T, key: string, iterationNumber: number) => U): Promise { + return this._run( + () => this._primary.iterate(iteratee), + () => this._fallback.iterate(iteratee) + ); + } +} + function createDriver(): IStorageDriver { try { if (typeof indexedDB !== 'undefined') { - return new IndexedDBDriver(); + return new FallbackStorageDriver(new IndexedDBDriver(), new LocalStorageDriver()); } } catch { // fall through diff --git a/packages/ui/src/views/components/context-menu/ContextMenu.tsx b/packages/ui/src/views/components/context-menu/ContextMenu.tsx index edd68fc0a4..9ba06fa4b2 100644 --- a/packages/ui/src/views/components/context-menu/ContextMenu.tsx +++ b/packages/ui/src/views/components/context-menu/ContextMenu.tsx @@ -14,8 +14,7 @@ * limitations under the License. */ -import type { IMouseEvent } from '@univerjs/engine-render'; -import type { IContextMenuTriggerContext } from '../../../services/contextmenu/contextmenu.service'; +import type { ContextMenuEvent, IContextMenuTriggerContext } from '../../../services/contextmenu/contextmenu.service'; import type { IContextMenuAnchorRect } from './AnchoredContextMenu'; import { ICommandService } from '@univerjs/core'; import { useEffect, useRef, useState } from 'react'; @@ -58,7 +57,7 @@ export function DesktopContextMenu() { }, [contextMenuService]); /** A function to open context menu with given position and menu type. */ - function handleContextMenu(event: IMouseEvent, menuType: string, context?: IContextMenuTriggerContext) { + function handleContextMenu(event: ContextMenuEvent, menuType: string, context?: IContextMenuTriggerContext) { setVisible(false); requestAnimationFrame(() => { setMenuType(menuType); diff --git a/packages/ui/src/views/components/context-menu/MobileContextMenu.tsx b/packages/ui/src/views/components/context-menu/MobileContextMenu.tsx index f428e7717c..7b2accc5df 100644 --- a/packages/ui/src/views/components/context-menu/MobileContextMenu.tsx +++ b/packages/ui/src/views/components/context-menu/MobileContextMenu.tsx @@ -14,12 +14,11 @@ * limitations under the License. */ -import type { IMouseEvent } from '@univerjs/engine-render'; +import type { ComponentProps } from 'react'; import type { LocaleKey } from '../../../locale/types'; -import type { IContextMenuTriggerContext } from '../../../services/contextmenu/contextmenu.service'; +import type { ContextMenuEvent, IContextMenuTriggerContext } from '../../../services/contextmenu/contextmenu.service'; import { ICommandService, LocaleService } from '@univerjs/core'; import { ConfigContext } from '@univerjs/design'; -import { CloseIcon } from '@univerjs/icons'; import { useContext, useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { IContextMenuHostService } from '../../../services/contextmenu/contextmenu-host.service'; @@ -28,8 +27,9 @@ import { ILayoutService } from '../../../services/layout/layout.service'; import { IMenuManagerService } from '../../../services/menu/menu-manager.service'; import { ContextMenuPosition } from '../../../services/menu/types'; import { IUIRuntimeScopeService } from '../../../services/runtime-scope/ui-runtime-scope.service'; -import { useDependency, useObservable } from '../../../utils/di'; +import { useDependency } from '../../../utils/di'; import { MobileMenu } from '../../menu/mobile/MobileMenu'; +import { MobileMenuDrawer } from '../../menu/mobile/MobileMenuDrawer'; const MOBILE_CONTEXT_MENU_HOST_ID = 'mobile-context-menu'; @@ -37,7 +37,9 @@ export function MobileContextMenu() { const [visible, setVisible] = useState(false); const [menuType, setMenuType] = useState(''); const [menuContext, setMenuContext] = useState(); + const [anchor, setAnchor] = useState({ x: 0, y: 0 }); const visibleRef = useRef(visible); + const floatingMenuRef = useRef(null); const contextMenuHostService = useDependency(IContextMenuHostService); const contextMenuService = useDependency(IContextMenuService); const commandService = useDependency(ICommandService); @@ -45,7 +47,6 @@ export function MobileContextMenu() { const menuManagerService = useDependency(IMenuManagerService); const runtimeScopeService = useDependency(IUIRuntimeScopeService); const localeService = useDependency(LocaleService); - const direction = useObservable(localeService.direction$); const { mountContainer } = useContext(ConfigContext); visibleRef.current = visible; @@ -72,10 +73,11 @@ export function MobileContextMenu() { }; }, [contextMenuHostService, contextMenuService]); - function handleContextMenu(_event: IMouseEvent, nextMenuType: string, context?: IContextMenuTriggerContext) { + function handleContextMenu(event: ContextMenuEvent, nextMenuType: string, context?: IContextMenuTriggerContext) { contextMenuHostService.activateMenu(MOBILE_CONTEXT_MENU_HOST_ID); setMenuType(nextMenuType); setMenuContext(context); + setAnchor({ x: event.clientX, y: event.clientY }); setVisible(true); } @@ -84,6 +86,22 @@ export function MobileContextMenu() { contextMenuHostService.deactivateMenu(MOBILE_CONTEXT_MENU_HOST_ID); } + useEffect(() => { + if (!visible || menuType !== ContextMenuPosition.MAIN_AREA || !mountContainer) { + return undefined; + } + + const ownerDocument = mountContainer.ownerDocument; + const handleOutsidePointerDown = (event: PointerEvent) => { + if (!(event.target instanceof Node) || !floatingMenuRef.current?.contains(event.target)) { + setVisible(false); + contextMenuHostService.deactivateMenu(MOBILE_CONTEXT_MENU_HOST_ID); + } + }; + ownerDocument.addEventListener('pointerdown', handleOutsidePointerDown, true); + return () => ownerDocument.removeEventListener('pointerdown', handleOutsidePointerDown, true); + }, [contextMenuHostService, menuType, mountContainer, visible]); + const sheetTitle = useMemo(() => { switch (menuType) { case ContextMenuPosition.ROW_HEADER: @@ -109,82 +127,77 @@ export function MobileContextMenu() { const activeMenuManagerService = activeScope?.has(IMenuManagerService) ? activeScope.get(IMenuManagerService) : menuManagerService; - const menu = ( - ['onOptionSelect']>>[0]) => { + const commandId = params.commandId ?? params.id ?? (typeof params.label === 'string' ? params.label : undefined); + const fallbackParams = typeof params.params === 'function' ? params.params() : params.params; + const optionParams = typeof params.value === 'undefined' ? fallbackParams : { value: params.value }; + const commandParams = menuContext + ? { ...menuContext, ...optionParams } + : optionParams; + + if (!commandId) { + return; + } + + activeLayoutService.focus(); + activeCommandService.executeCommand(commandId, commandParams); + handleClose(); + }; + if (menuType === ContextMenuPosition.MAIN_AREA) { + const viewportHeight = mountContainer.ownerDocument.defaultView?.innerHeight ?? 0; + const placeBelow = anchor.y < 72; + const top = placeBelow + ? anchor.y + 12 + : Math.min(anchor.y - 56, Math.max(8, viewportHeight - 56)); + const pointerLeft = `clamp(20px, ${anchor.x - 8}px, calc(100% - 20px))`; + + return createPortal( +
    +
    + + +
    , + mountContainer + ); + } + + return ( + { - const commandId = params.commandId ?? params.id ?? params.label as string | undefined; - const fallbackParams = typeof params.params === 'function' ? params.params() : params.params; - const commandParams = typeof params.value === 'undefined' ? fallbackParams : { value: params.value }; - - if (!commandId) { - return; - } - - activeLayoutService.focus(); - activeCommandService.executeCommand(commandId, commandParams); - handleClose(); - }} + onClose={handleClose} + onOptionSelect={handleOptionSelect} /> ); - - return createPortal( -
    - -
    -
    - {menuType && ( - menu - )} - - , - mountContainer - ); } diff --git a/packages/ui/src/views/components/dialog-part/MobileDialogPart.tsx b/packages/ui/src/views/components/dialog-part/MobileDialogPart.tsx new file mode 100644 index 0000000000..2821ee39cc --- /dev/null +++ b/packages/ui/src/views/components/dialog-part/MobileDialogPart.tsx @@ -0,0 +1,133 @@ +/** + * 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 { ReactNode } from 'react'; +import type { LocaleKey } from '../../../locale/types'; +import type { MobileDrawerSnap } from '../mobile-drawer/MobileDrawer'; +import type { IDialogPartMethodOptions } from './interface'; +import { LocaleService } from '@univerjs/core'; +import { ActionRow } from '@univerjs/design'; +import { CloseIcon } from '@univerjs/icons'; +import { useMemo, useState } from 'react'; +import { IDialogService } from '../../../services/dialog/dialog.service'; +import { useDependency, useObservable } from '../../../utils/di'; +import { CustomLabel } from '../../custom-label/CustomLabel'; +import { MobileDrawer } from '../mobile-drawer/MobileDrawer'; + +interface IMobileDialogOptions extends Omit { + children?: ReactNode; + title?: ReactNode; + footer?: ReactNode; +} + +function toMobileDialogOptions(options: IDialogPartMethodOptions): IMobileDialogOptions { + const { children, title, footer, ...rest } = options; + return { + ...rest, + children: children ? : undefined, + title: title ? : undefined, + footer: footer ? : undefined, + }; +} + +export function MobileDialogPart() { + const dialogService = useDependency(IDialogService); + const localeService = useDependency(LocaleService); + const dialogOptions = useObservable(dialogService.getDialogs$(), []); + const options = useMemo(() => { + const activeDialogs = dialogOptions.filter((item) => item.open !== false); + const active = activeDialogs[activeDialogs.length - 1]; + return active ? toMobileDialogOptions(active) : null; + }, [dialogOptions]); + const [drawerSnap, setDrawerSnap] = useState('expanded'); + + if (!options) return null; + + const close = () => { + dialogService.close(options.id); + options.onClose?.(); + options.onOpenChange?.(false); + }; + + return ( +
    + + )} + + )} + footer={options.footer + ? ( +
    + {options.footer} +
    + ) + : undefined} + > + {options.children} + +
    + ); +} diff --git a/packages/ui/src/views/components/dialog-part/__tests__/MobileDialogPart.spec.tsx b/packages/ui/src/views/components/dialog-part/__tests__/MobileDialogPart.spec.tsx new file mode 100644 index 0000000000..29389045cb --- /dev/null +++ b/packages/ui/src/views/components/dialog-part/__tests__/MobileDialogPart.spec.tsx @@ -0,0 +1,131 @@ +/** + * 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 { ReactElement } from 'react'; +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { DesktopLogService, ILogService, Injector, LocaleService, LocaleType } from '@univerjs/core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { ComponentManager, IconManager } from '../../../../common'; +import enUS from '../../../../locale/en-US'; +import { DesktopDialogService } from '../../../../services/dialog/desktop-dialog.service'; +import { IDialogService } from '../../../../services/dialog/dialog.service'; +import { MobileDialogService } from '../../../../services/dialog/mobile-dialog.service'; +import { IUIPartsService, UIPartsService } from '../../../../services/parts/parts.service'; +import { RediProvider } from '../../../../utils/di'; +import { MobileDialogPart } from '../MobileDialogPart'; + +Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true, writable: true }); + +function renderWithDependencies(element: ReactElement, mobileService = false) { + const injector = new Injector(); + injector.add([IUIPartsService, { useClass: UIPartsService }]); + injector.add([IDialogService, { useClass: mobileService ? MobileDialogService : DesktopDialogService }]); + injector.add([ILogService, { useClass: DesktopLogService }]); + injector.add([LocaleService]); + injector.get(LocaleService).load({ [LocaleType.EN_US]: enUS }); + injector.get(LocaleService).setLocale(LocaleType.EN_US); + injector.add([ComponentManager]); + injector.add([IconManager]); + + return { + ...render({element}), + injector, + }; +} + +describe('MobileDialogPart', () => { + afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + }); + + it('renders the latest active dialog as an 80 percent bottom drawer', () => { + const rendered = renderWithDependencies(); + const dialogService = rendered.injector.get(IDialogService); + + act(() => { + dialogService.open({ id: 'first', children: { title: First } }); + dialogService.open({ + id: 'second', + title: { title: Range selector }, + children: { title: Second }, + footer: { title: Actions }, + }); + }); + + const drawer = screen.getByRole('dialog'); + expect(drawer.getAttribute('data-snap')).toBe('expanded'); + expect(screen.queryByText('First')).toBeNull(); + expect(screen.getByText('Range selector')).toBeTruthy(); + expect(screen.getByText('Second')).toBeTruthy(); + expect(screen.getByText('Actions')).toBeTruthy(); + }); + + it('closes with the close button and invokes dialog callbacks', () => { + const rendered = renderWithDependencies(); + const dialogService = rendered.injector.get(IDialogService); + const onClose = vi.fn(); + const onOpenChange = vi.fn(); + + act(() => { + dialogService.open({ + id: 'pivot', + children: { title: Pivot }, + onClose, + onOpenChange, + }); + }); + + fireEvent.click(screen.getAllByRole('button', { name: 'Close sidebar' })[1]); + expect(screen.queryByRole('dialog')).toBeNull(); + expect(onClose).toHaveBeenCalledOnce(); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + + it('keeps a non-mask-closable dialog open when its backdrop is tapped', () => { + const rendered = renderWithDependencies(); + const dialogService = rendered.injector.get(IDialogService); + + act(() => { + dialogService.open({ + id: 'required', + children: { title: Required input }, + maskClosable: false, + }); + }); + + fireEvent.click(screen.getAllByRole('button', { name: 'Close sidebar' })[0]); + expect(screen.getByRole('dialog')).toBeTruthy(); + }); + + it('makes re-entrant business close callbacks idempotent', () => { + const rendered = renderWithDependencies(, true); + const dialogService = rendered.injector.get(IDialogService); + const onClose = vi.fn(() => dialogService.close('table-selector')); + + act(() => { + dialogService.open({ + id: 'table-selector', + children: { title: Table selector }, + onClose, + }); + }); + + expect(() => fireEvent.click(screen.getAllByRole('button', { name: 'Close sidebar' })[1])).not.toThrow(); + expect(onClose).toHaveBeenCalledOnce(); + expect(screen.queryByRole('dialog')).toBeNull(); + }); +}); diff --git a/packages/ui/src/views/components/mobile-drawer/MobileDrawer.tsx b/packages/ui/src/views/components/mobile-drawer/MobileDrawer.tsx new file mode 100644 index 0000000000..3d4c91b273 --- /dev/null +++ b/packages/ui/src/views/components/mobile-drawer/MobileDrawer.tsx @@ -0,0 +1,228 @@ +/** + * 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 { AriaRole, PointerEvent, ReactNode } from 'react'; +import { clsx, resetButtonClassName, scrollbarClassName } from '@univerjs/design'; +import { useRef, useState } from 'react'; + +export type MobileDrawerSnap = 'compact' | 'expanded'; +export type MobileDrawerRelease = MobileDrawerSnap | 'closed'; +type MobileDrawerRef = { current: T | null } | ((instance: T | null) => void) | null; + +const MOBILE_DRAWER_COMPACT_PERCENT = 40; +const MOBILE_DRAWER_EXPANDED_PERCENT = 80; + +export function resolveMobileDrawerRelease(params: { + snap: MobileDrawerSnap; + deltaY: number; + durationMs: number; + percent: number; +}): 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 (snap === 'expanded' && deltaY > 24) return 'compact'; + if (snap === 'compact' && deltaY < -24) return 'expanded'; + return percent >= 60 ? 'expanded' : 'compact'; +} + +export function MobileDrawer(props: { + snap: MobileDrawerSnap; + expandLabel: string; + collapseLabel: string; + onSnapChange: (snap: MobileDrawerSnap) => void; + onClose: () => void; + children?: ReactNode; + header?: ReactNode; + floatingActions?: ReactNode; + componentName?: string; + panelRef?: MobileDrawerRef; + contentRef?: MobileDrawerRef; + panelClassName?: string; + contentClassName?: string; + footer?: ReactNode; + role?: AriaRole; + ariaLabel?: string; +}) { + const { + snap, + expandLabel, + collapseLabel, + onSnapChange, + onClose, + children, + header, + floatingActions, + componentName = 'mobile-drawer', + panelRef, + contentRef, + panelClassName, + contentClassName, + footer, + role, + ariaLabel, + } = props; + const [dragPercent, setDragPercent] = useState(null); + const suppressHandleClickRef = useRef(false); + const dragRef = useRef<{ + startY: number; + startTime: number; + startPercent: number; + currentPercent: number; + moved: boolean; + } | null>(null); + const drawerPercent = dragPercent ?? (snap === 'compact' + ? MOBILE_DRAWER_COMPACT_PERCENT + : MOBILE_DRAWER_EXPANDED_PERCENT); + + function beginDrag(clientY: number) { + dragRef.current = { + startY: clientY, + startTime: performance.now(), + startPercent: drawerPercent, + currentPercent: drawerPercent, + moved: false, + }; + } + + function moveDrag(clientY: number) { + const drag = dragRef.current; + if (!drag) return; + + const deltaY = clientY - drag.startY; + const nextPercent = Math.max(0, Math.min( + MOBILE_DRAWER_EXPANDED_PERCENT, + drag.startPercent - deltaY / Math.max(window.innerHeight, 1) * 100 + )); + drag.currentPercent = nextPercent; + drag.moved ||= Math.abs(deltaY) > 6; + setDragPercent(nextPercent); + } + + function endDrag(clientY: number) { + const drag = dragRef.current; + if (!drag) return; + + const result = resolveMobileDrawerRelease({ + snap, + deltaY: clientY - drag.startY, + durationMs: performance.now() - drag.startTime, + percent: drag.currentPercent, + }); + suppressHandleClickRef.current = drag.moved; + dragRef.current = null; + setDragPercent(null); + if (result === 'closed') { + onClose(); + } else { + onSnapChange(result); + } + } + + function handlePointerDown(event: PointerEvent) { + try { + event.currentTarget.setPointerCapture(event.pointerId); + } catch { + // Some embedded WebViews expose Pointer Events but not pointer capture. + } + beginDrag(event.clientY); + } + + function handlePointerMove(event: PointerEvent) { + moveDrag(event.clientY); + } + + function handlePointerUp(event: PointerEvent) { + endDrag(event.clientY); + } + + return ( + <> + {floatingActions && ( +
    + {floatingActions} +
    + )} +
    +
    + + {header} +
    +
    + {children} +
    + {footer} +
    + + ); +} diff --git a/packages/ui/src/views/components/mobile-drawer/__tests__/MobileDrawer.spec.ts b/packages/ui/src/views/components/mobile-drawer/__tests__/MobileDrawer.spec.ts new file mode 100644 index 0000000000..9d74fbe5be --- /dev/null +++ b/packages/ui/src/views/components/mobile-drawer/__tests__/MobileDrawer.spec.ts @@ -0,0 +1,72 @@ +/** + * 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 { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { createElement } from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { MobileDrawer, resolveMobileDrawerRelease } from '../MobileDrawer'; + +afterEach(cleanup); + +describe('mobile drawer snap behavior', () => { + it('supports expand, collapse, restore, and fast 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: 120, durationMs: 150, percent: 25 })).toBe('closed'); + }); + + it('expands when the handle is dragged upward', () => { + const onSnapChange = vi.fn(); + const onClose = vi.fn(); + const { container } = 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: 600 }); + fireEvent.pointerMove(handle, { pointerId: 1, clientY: 500 }); + expect(container.querySelector('section')?.style.height).not.toBe('40dvh'); + fireEvent.pointerUp(handle, { pointerId: 1, clientY: 500 }); + + expect(onSnapChange).toHaveBeenCalledWith('expanded'); + expect(onClose).not.toHaveBeenCalled(); + }); + + it('closes when the compact handle is flicked downward', () => { + 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.pointerUp(handle, { pointerId: 1, clientY: 650 }); + + expect(onClose).toHaveBeenCalledOnce(); + expect(onSnapChange).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/ui/src/views/components/ribbon/MobileRibbon.tsx b/packages/ui/src/views/components/ribbon/MobileRibbon.tsx index 8caf334025..3e843ae4da 100644 --- a/packages/ui/src/views/components/ribbon/MobileRibbon.tsx +++ b/packages/ui/src/views/components/ribbon/MobileRibbon.tsx @@ -16,18 +16,22 @@ import type { ComponentType } from 'react'; import type { LocaleKey } from '../../../locale/types'; -import { LocaleService } from '@univerjs/core'; +import type { IMenuSchema } from '../../../services/menu/menu-manager.service'; +import { LocaleService, UniverInstanceType } from '@univerjs/core'; import { borderBottomClassName, borderClassName, borderRightClassName, clsx, + ConfigContext, resetButtonClassName, } from '@univerjs/design'; -import { MoreLeftIcon, MoreRightIcon } from '@univerjs/icons'; -import { useEffect, useMemo, useRef, useState } from 'react'; -import { RibbonPosition } from '../../../services/menu/types'; +import { MoreHorizontalIcon, MoreLeftIcon, MoreRightIcon } from '@univerjs/icons'; +import { useContext, useEffect, useMemo, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { RibbonPosition, RibbonStartGroup } from '../../../services/menu/types'; import { IRibbonService } from '../../../services/ribbon/ribbon.service'; +import { IWorkbenchService } from '../../../services/workbench/workbench.service'; import { useDependency, useObservable } from '../../../utils/di'; import { ComponentContainer } from '../ComponentContainer'; import { ToolbarItem } from './ToolbarItem'; @@ -42,11 +46,37 @@ const toolbarScrollOffset = 168; export function MobileRibbon(props: IMobileRibbonProps) { const { headerMenuComponents, headerMenu = true } = props; - const localeService = useDependency(LocaleService); const ribbonService = useDependency(IRibbonService); + const workbenchService = useDependency(IWorkbenchService); const ribbon = useObservable(ribbonService.ribbon$, []); const activatedTab = useObservable(ribbonService.activatedTab$, RibbonPosition.START); + const rootUnitType = useObservable(workbenchService.rootUnitType$, null, true); + + if (rootUnitType === UniverInstanceType.UNIVER_SHEET) { + return ( + + ); + } + + return ( + + ); +} + +function MobileRibbonToolbar(props: IMobileRibbonProps & { ribbon: IMenuSchema[]; activatedTab: string }) { + const { ribbon, activatedTab, headerMenuComponents, headerMenu = true } = props; + const localeService = useDependency(LocaleService); + const ribbonService = useDependency(IRibbonService); const activeIndex = useMemo(() => { const index = ribbon.findIndex((group) => group.key === activatedTab); @@ -356,3 +386,126 @@ export function MobileRibbon(props: IMobileRibbonProps) { ); } + +function MobileSheetTopBar(props: IMobileRibbonProps & { ribbon: IMenuSchema[] }) { + const { headerMenuComponents, headerMenu = true, ribbon } = props; + const [moreOpen, setMoreOpen] = useState(false); + const moreTriggerRef = useRef(null); + const { mountContainer } = useContext(ConfigContext); + const localeService = useDependency(LocaleService); + const startGroup = ribbon.find((group) => group.key === RibbonPosition.START); + const historyItems = startGroup?.children?.find((group) => group.key === RibbonStartGroup.HISTORY)?.children ?? []; + const otherItems = startGroup?.children?.find((group) => group.key === RibbonStartGroup.OTHERS)?.children ?? []; + const items = historyItems.filter((schema) => schema.item); + const moreItems = otherItems.flatMap((schema) => schema.children?.length ? schema.children : [schema]) + .filter((schema) => schema.item); + const hasHeaderMenu = !!(headerMenu && headerMenuComponents && headerMenuComponents.size > 0); + + if (items.length === 0 && moreItems.length === 0 && !hasHeaderMenu) { + return null; + } + + const triggerRect = moreOpen ? moreTriggerRef.current?.getBoundingClientRect() : undefined; + const viewportWidth = moreTriggerRef.current?.ownerDocument.defaultView?.innerWidth ?? 0; + const moreMenu = moreOpen && mountContainer && createPortal( + <> + + )} + + {moreMenu} + + ); +} diff --git a/packages/ui/src/views/components/ribbon/TooltipButtonWrapper.tsx b/packages/ui/src/views/components/ribbon/TooltipButtonWrapper.tsx index 451253c975..f98ac7162c 100644 --- a/packages/ui/src/views/components/ribbon/TooltipButtonWrapper.tsx +++ b/packages/ui/src/views/components/ribbon/TooltipButtonWrapper.tsx @@ -353,7 +353,8 @@ export function DropdownMenuWrapper({ const items: IDropdownMenuProps['items'] = options.map((option) => ({ type: 'item', className: clsx({ - 'focus:univer-bg-gray-0': typeof option.label !== 'string' && option.label?.hoverable === false, + 'focus:univer-bg-gray-0 dark:focus:!univer-bg-gray-800': + typeof option.label !== 'string' && option.label?.hoverable === false, '!univer-p-0': isSingleEmbeddedCustomPanel, }), children: ( diff --git a/packages/ui/src/views/components/ribbon/__tests__/MobileRibbon.spec.tsx b/packages/ui/src/views/components/ribbon/__tests__/MobileRibbon.spec.tsx new file mode 100644 index 0000000000..8414ec1495 --- /dev/null +++ b/packages/ui/src/views/components/ribbon/__tests__/MobileRibbon.spec.tsx @@ -0,0 +1,54 @@ +/** + * 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. + */ + +/** + * @vitest-environment jsdom + */ + +import { act, cleanup, render } from '@testing-library/react'; +import { Injector, LocaleService, UniverInstanceType } from '@univerjs/core'; +import { connectInjector } from '@wendellhu/redi/react-bindings'; +import { BehaviorSubject, of } from 'rxjs'; +import { afterEach, describe, expect, it } from 'vitest'; +import { IRibbonService } from '../../../../services/ribbon/ribbon.service'; +import { IWorkbenchService } from '../../../../services/workbench/workbench.service'; +import { MobileRibbon } from '../MobileRibbon'; + +describe('MobileRibbon', () => { + afterEach(cleanup); + + it('keeps a stable hook order when the sheet workbench becomes ready', () => { + const rootUnitType$ = new BehaviorSubject(null); + const injector = new Injector([ + [LocaleService, { useValue: { t: (key: string) => key } }], + [IRibbonService, { + useValue: { + ribbon$: of([]), + activatedTab$: of(''), + setActivatedTab: () => {}, + }, + }], + [IWorkbenchService, { useValue: { rootUnitType$ } }], + ]); + const ConnectedMobileRibbon = connectInjector(MobileRibbon, injector); + + render(); + + expect(() => { + act(() => rootUnitType$.next(UniverInstanceType.UNIVER_SHEET)); + }).not.toThrow(); + }); +}); diff --git a/packages/ui/src/views/components/sidebar/MobileSidebar.tsx b/packages/ui/src/views/components/sidebar/MobileSidebar.tsx new file mode 100644 index 0000000000..d75404982e --- /dev/null +++ b/packages/ui/src/views/components/sidebar/MobileSidebar.tsx @@ -0,0 +1,126 @@ +/** + * 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 { LocaleKey } from '../../../locale/types'; +import type { MobileDrawerSnap } from '../mobile-drawer/MobileDrawer'; +import type { ISidebarMethodOptions } from './Sidebar'; +import { LocaleService } from '@univerjs/core'; +import { ActionRow } from '@univerjs/design'; +import { CloseIcon } from '@univerjs/icons'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { ISidebarService } from '../../../services/sidebar/sidebar.service'; +import { useDependency, useObservable } from '../../../utils/di'; +import { MobileDrawer } from '../mobile-drawer/MobileDrawer'; +import { renderSidebarOptions } from './Sidebar'; + +export function MobileSidebar() { + const localeService = useDependency(LocaleService); + const sidebarService = useDependency(ISidebarService); + const sidebarOptions = useObservable(sidebarService.sidebarOptions$); + const scrollRef = useRef(null); + const closeButtonRef = useRef(null); + const [drawerSnap, setDrawerSnap] = useState('expanded'); + const options = useMemo(() => renderSidebarOptions(sidebarOptions), [sidebarOptions]); + + useEffect(() => { + if (options?.visible) closeButtonRef.current?.focus(); + }, [options?.visible]); + + useEffect(() => { + const scrollElement = scrollRef.current; + sidebarService.setContainer(scrollElement ?? undefined); + const handleScroll = (event: Event) => sidebarService.scrollEvent$.next(event); + scrollElement?.addEventListener('scroll', handleScroll); + return () => { + scrollElement?.removeEventListener('scroll', handleScroll); + sidebarService.setContainer(undefined); + }; + }, [options?.visible, sidebarService]); + + if (!options?.visible) return null; + + const close = () => sidebarService.close(sidebarOptions?.id); + + return ( +
    + + + )} + footer={options.footer + ? ( +
    + {options.footer} +
    + ) + : undefined} + > + {options.children} + +
    + ); +} diff --git a/packages/ui/src/views/components/sidebar/Sidebar.tsx b/packages/ui/src/views/components/sidebar/Sidebar.tsx index d104cc7d83..62cdbb6230 100644 --- a/packages/ui/src/views/components/sidebar/Sidebar.tsx +++ b/packages/ui/src/views/components/sidebar/Sidebar.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import type { ReactNode } from 'react'; +import type { Attributes, ReactNode } from 'react'; import type { LocaleKey } from '../../../locale/types'; import type { ICustomLabelProps } from '../../custom-label/CustomLabel'; import { LocaleService } from '@univerjs/core'; @@ -25,11 +25,13 @@ import { ISidebarService } from '../../../services/sidebar/sidebar.service'; import { useDependency, useObservable } from '../../../utils/di'; import { CustomLabel } from '../../custom-label/CustomLabel'; +type SidebarCustomLabelProps = ICustomLabelProps & Attributes; + export interface ISidebarMethodOptions { id?: string; - header?: ICustomLabelProps; - children?: ICustomLabelProps; - footer?: ICustomLabelProps; + header?: SidebarCustomLabelProps; + children?: SidebarCustomLabelProps; + footer?: SidebarCustomLabelProps; visible?: boolean; @@ -39,6 +41,31 @@ export interface ISidebarMethodOptions { onOpen?: () => void; } +export interface IRenderedSidebarOptions extends Omit { + children?: ReactNode; + footer?: ReactNode; + header?: ReactNode; +} + +export function renderSidebarOptions(options?: ISidebarMethodOptions): IRenderedSidebarOptions | null { + if (!options) return null; + + const { children, footer, header, ...rest } = options; + const renderLabel = (label?: SidebarCustomLabelProps) => { + if (!label) return undefined; + + const { key, ...props } = label; + return ; + }; + + return { + ...rest, + children: renderLabel(children), + footer: renderLabel(footer), + header: renderLabel(header), + }; +} + const MIN_SIDEBAR_WIDTH = 280; const MAX_SIDEBAR_WIDTH = 800; @@ -52,31 +79,7 @@ export function Sidebar() { const [isDragging, setIsDragging] = useState(false); const dragWidthRef = useRef(null); - const options = useMemo(() => { - if (!sidebarOptions) { - return null; - } - - const copy = { ...sidebarOptions } as Omit & { - children?: ReactNode; - header?: ReactNode; - footer?: ReactNode; - }; - - for (const key of ['children', 'header', 'footer']) { - const k = key as keyof ISidebarMethodOptions; - - if (sidebarOptions[k]) { - const { key: itemKey, ...props } = sidebarOptions[k] as any; - - if (props) { - (copy as any)[k] = ; - } - } - } - - return copy; - }, [sidebarOptions]); + const options = useMemo(() => renderSidebarOptions(sidebarOptions), [sidebarOptions]); // Focus management: move focus into the sidebar when it opens useEffect(() => { diff --git a/packages/ui/src/views/components/sidebar/__tests__/Sidebar.spec.tsx b/packages/ui/src/views/components/sidebar/__tests__/Sidebar.spec.tsx index 970072245a..931b3026f7 100644 --- a/packages/ui/src/views/components/sidebar/__tests__/Sidebar.spec.tsx +++ b/packages/ui/src/views/components/sidebar/__tests__/Sidebar.spec.tsx @@ -23,6 +23,7 @@ import enUS from '../../../../locale/en-US'; import { DesktopSidebarService } from '../../../../services/sidebar/desktop-sidebar.service'; import { ISidebarService } from '../../../../services/sidebar/sidebar.service'; import { RediProvider } from '../../../../utils/di'; +import { MobileSidebar } from '../MobileSidebar'; import { Sidebar } from '../Sidebar'; (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; @@ -113,4 +114,25 @@ describe('Sidebar', () => { expect(sidebarService.width).toBe(800); rendered.dispose(); }); + + it('renders sidebar content as a modal bottom drawer on mobile', () => { + const rendered = renderWithDependencies(); + const sidebarService = rendered.injector.get(ISidebarService); + + act(() => { + sidebarService.open({ + id: 'comments', + header: { title: Comments }, + children: { title: Comment list }, + }); + }); + + const drawer = screen.getByRole('dialog', { name: 'Sidebar panel' }); + expect(drawer.className).toContain('univer-bottom-0'); + expect(screen.getByText('Comment list')).toBeTruthy(); + + fireEvent.click(screen.getAllByRole('button', { name: 'Close sidebar' })[0]); + expect(screen.queryByRole('dialog', { name: 'Sidebar panel' })).toBeNull(); + rendered.dispose(); + }); }); diff --git a/packages/ui/src/views/menu/mobile/MobileMenu.tsx b/packages/ui/src/views/menu/mobile/MobileMenu.tsx index 94b755ebca..36515f1f87 100644 --- a/packages/ui/src/views/menu/mobile/MobileMenu.tsx +++ b/packages/ui/src/views/menu/mobile/MobileMenu.tsx @@ -18,7 +18,6 @@ import type { Observable } from 'rxjs'; import type { LocaleKey } from '../../../locale/types'; import type { IDisplayMenuItem, - IMenuButtonItem, IMenuItem, IMenuSelectorItem, IValueOption, @@ -29,9 +28,10 @@ import type { IBaseMenuProps } from '../types'; import { LocaleService } from '@univerjs/core'; import { borderBottomClassName, clsx } from '@univerjs/design'; import { CheckMarkIcon, MoreLeftIcon, MoreRightIcon } from '@univerjs/icons'; -import { useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { combineLatest, isObservable, map, merge, of } from 'rxjs'; import { scan, startWith } from 'rxjs/operators'; +import { ComponentManager } from '../../../common/component-manager'; import { MenuItemType } from '../../../services/menu/menu'; import { IMenuManagerService } from '../../../services/menu/menu-manager.service'; import { useDependency, useObservable } from '../../../utils/di'; @@ -48,7 +48,7 @@ type MobileMenuView = kind: 'options'; title?: string; options: IValueOption[]; - menuItem: IDisplayMenuItem>; + menuItem: IDisplayMenuItem>; menuKey: string; currentValue: MenuItemDefaultValueType; disabled$?: Observable; @@ -57,14 +57,26 @@ type MobileMenuView = interface IMobileMenuProps extends IBaseMenuProps { schemas?: IMenuSchema[]; menuManagerService?: IMenuManagerService; + showHeader?: boolean; + presentation?: 'drawer' | 'context-bar'; + onNavigationChange?: (navigation: { title?: string; onBack: () => void } | null) => void; } export function MobileMenu(props: IMobileMenuProps) { - const { menuType, onOptionSelect, schemas: providedSchemas, menuManagerService: providedMenuManagerService } = props; + const { + menuType, + onOptionSelect, + schemas: providedSchemas, + menuManagerService: providedMenuManagerService, + showHeader = true, + presentation = 'drawer', + onNavigationChange, + } = props; const rootMenuManagerService = useDependency(IMenuManagerService); const localeService = useDependency(LocaleService); const menuManagerService = providedMenuManagerService ?? rootMenuManagerService; const [viewStack, setViewStack] = useState([]); + const providedSchemaKey = providedSchemas?.map((schema) => schema.key).join('|'); const menuSchemaVersion$ = useMemo(() => { return menuManagerService.menuChanged$.pipe( @@ -88,25 +100,45 @@ export function MobileMenu(props: IMobileMenuProps) { useEffect(() => { setViewStack([]); - }, [menuType, providedSchemas]); + }, [menuType, providedSchemaKey]); const currentView = viewStack[viewStack.length - 1] ?? null; + const closeView = useCallback(() => setViewStack((stack) => stack.slice(0, -1)), []); + + useEffect(() => { + onNavigationChange?.(currentView + ? { title: currentView.title, onBack: closeView } + : null); + }, [closeView, currentView, onNavigationChange]); if (!menuType && !providedSchemas) { return null; } const openView = (view: MobileMenuView) => setViewStack((stack) => [...stack, view]); - const closeView = () => setViewStack((stack) => stack.slice(0, -1)); + + if (presentation === 'context-bar') { + return ( + + ); + } return ( -
    - {currentView && ( +
    + {showHeader && currentView && (
    )} -
    +
    {currentView?.kind !== 'options' && ( -
    +
    +
    void; + onBack: () => void; +}) { + const { currentView, menuSchemas, menuManagerService, onExecute, onOpenView, onBack } = props; + const localeService = useDependency(LocaleService); + const scrollerRef = useRef(null); + const [scrollState, setScrollState] = useState({ left: false, right: false }); + const schemas = getContextBarSchemas(currentView?.kind === 'schema' ? currentView.schemas : menuSchemas); + + const updateScrollState = useCallback(() => { + const scroller = scrollerRef.current; + if (!scroller) { + return; + } + + setScrollState({ + left: scroller.scrollLeft > 1, + right: scroller.scrollLeft + scroller.clientWidth < scroller.scrollWidth - 1, + }); + }, []); + + useLayoutEffect(() => { + const scroller = scrollerRef.current; + if (scroller) { + scroller.scrollLeft = 0; + } + const frame = requestAnimationFrame(updateScrollState); + return () => cancelAnimationFrame(frame); + }, [currentView, menuSchemas, updateScrollState]); + + const scroll = (direction: -1 | 1) => { + const scroller = scrollerRef.current; + scroller?.scrollBy({ left: direction * scroller.clientWidth * 0.8, behavior: 'smooth' }); + }; + + const showLeft = Boolean(currentView) || scrollState.left; + + return ( +
    + {showLeft && ( + + )} +
    + {currentView?.kind === 'options' + ? currentView.options.map((option, index) => ( + + )) + : schemas.map((schema) => ( + + ))} +
    + {scrollState.right && ( + + )} +
    + ); +} + +function MobileContextMenuItem(props: { + schema: IMenuSchema; menuManagerService: IMenuManagerService; - menuType?: string; onExecute?: IBaseMenuProps['onOptionSelect']; onOpenView: (view: MobileMenuView) => void; inheritedDisabled$?: Observable; }) { - const { schemas, menuManagerService, menuType, onExecute, onOpenView, inheritedDisabled$ } = props; + const { schema, menuManagerService, onExecute, onOpenView, inheritedDisabled$ } = props; + const localeService = useDependency(LocaleService); + const interaction = useMobileSchemaInteraction({ schema, menuManagerService, onOpenView, inheritedDisabled$ }); + + if (!interaction || interaction.hidden) { + return null; + } + + return ( + + ); +} + +function MobileContextMenuOption(props: { + option: IValueOption; + menuKey: string; + menuItem: IDisplayMenuItem>; + currentValue: MenuItemDefaultValueType; + inheritedDisabled$?: Observable; + onExecute?: IBaseMenuProps['onOptionSelect']; +}) { + const { option, menuKey, menuItem, currentValue, inheritedDisabled$, onExecute } = props; + const inheritedDisabled = useObservable(inheritedDisabled$, false); + const observableValue = useObservable(option.value$); + const displayValue = option.value ?? observableValue; + const disabled = inheritedDisabled || Boolean(option.disabled); + + return ( + + ); +} + +function getContextBarSchemas(schemas: IMenuSchema[]): IMenuSchema[] { + return schemas.flatMap((schema) => schema.item ? [schema] : getContextBarSchemas(schema.children ?? [])); +} + +function MobileSchemaList(props: { + schemas: IMenuSchema[]; + menuManagerService: IMenuManagerService; + onExecute?: IBaseMenuProps['onOptionSelect']; + onOpenView: (view: MobileMenuView) => void; + inheritedDisabled$?: Observable; +}) { + const { schemas, menuManagerService, onExecute, onOpenView, inheritedDisabled$ } = props; const localeService = useDependency(LocaleService); const hiddenGroupStates = useContextGroupHiddenStates(schemas); const visibleSchemas = useMemo(() => { return schemas.filter((schema) => { + if (schema.item) { + return true; + } + if (schema.children?.length) { return !hiddenGroupStates[schema.key]; } - return !!schema.item; + return false; }); }, [hiddenGroupStates, schemas]); @@ -196,7 +452,6 @@ function MobileSchemaList(props: { key={schema.key} schema={schema} menuManagerService={menuManagerService} - menuType={menuType} onExecute={onExecute} onOpenView={onOpenView} inheritedDisabled$={inheritedDisabled$} @@ -222,6 +477,7 @@ function MobileSchemaList(props: { className=" univer-px-4 univer-pb-1 univer-pt-3 univer-text-xs univer-font-medium univer-uppercase univer-tracking-[0.08em] univer-text-gray-500 + dark:!univer-text-gray-400 " > {localeService.t(schema.title)} @@ -232,7 +488,6 @@ function MobileSchemaList(props: { key={childSchema.key} schema={childSchema} menuManagerService={menuManagerService} - menuType={menuType} onExecute={onExecute} onOpenView={onOpenView} inheritedDisabled$={inheritedDisabled$} @@ -249,56 +504,124 @@ function MobileSchemaList(props: { function MobileSchemaRow(props: { schema: IMenuSchema; menuManagerService: IMenuManagerService; - menuType?: string; onExecute?: IBaseMenuProps['onOptionSelect']; onOpenView: (view: MobileMenuView) => void; inheritedDisabled$?: Observable; bordered: boolean; }) { - const { schema, menuManagerService, menuType, onExecute, onOpenView, inheritedDisabled$, bordered } = props; - const interaction = useMobileSchemaInteraction({ schema, menuManagerService, menuType, onOpenView, inheritedDisabled$ }); + const { schema, menuManagerService, onExecute, onOpenView, inheritedDisabled$, bordered } = props; + const interaction = useMobileSchemaInteraction({ schema, menuManagerService, onOpenView, inheritedDisabled$ }); if (!interaction || interaction.hidden) { return null; } - const { menuItem, disabled, value, currentValueText, hasSubmenu, onPress } = interaction; + const { menuItem, activated, disabled, value, currentValueText, hasSubmenu, onPress } = interaction; + + if (typeof menuItem.label === 'object' && menuItem.label) { + return ( +
    div]:univer-box-border [&>div]:!univer-w-full + `, + bordered && borderBottomClassName + )} + data-u-command={menuItem.id} + onClick={(event) => { + if (event.target instanceof Element && event.target.closest('button, input, select, textarea, [role="button"]')) { + return; + } + + onPress(onExecute); + }} + > + { + if (disabled) { + return; + } + + onExecute?.({ + commandId: menuItem.commandId, + value: nextValue, + id: menuItem.id, + label: schema.key, + params: menuItem.params, + }); + }} + /> +
    + ); + } return ( ); } @@ -306,7 +629,7 @@ function MobileSchemaRow(props: { function MobileSelectionOptionsView(props: { options: IValueOption[]; menuKey: string; - menuItem: IDisplayMenuItem>; + menuItem: IDisplayMenuItem>; currentValue: MenuItemDefaultValueType; inheritedDisabled$?: Observable; onExecute?: IBaseMenuProps['onOptionSelect']; @@ -335,29 +658,73 @@ function MobileSelectionOptionsView(props: { function MobileSelectionOptionRow(props: { option: IValueOption; menuKey: string; - menuItem: IDisplayMenuItem>; + menuItem: IDisplayMenuItem>; currentValue: MenuItemDefaultValueType; disabled: boolean; bordered: boolean; onExecute?: IBaseMenuProps['onOptionSelect']; }) { const { option, menuKey, menuItem, currentValue, disabled, bordered, onExecute } = props; + const componentManager = useDependency(ComponentManager); const optionValue = useObservable(option.value$); const displayValue = option.value ?? optionValue; const selected = displayValue === currentValue; const optionDisabled = disabled || Boolean(option.disabled); + if (typeof option.label === 'object' && option.label && componentManager.get(option.label.name)) { + return ( +
    div>*]:univer-box-border [&>div>*]:!univer-w-full + [&>div]:univer-box-border [&>div]:!univer-w-full + `, + bordered && borderBottomClassName + )} + > + { + if (optionDisabled) { + return; + } + + onExecute?.({ + ...option, + value: nextValue, + id: menuItem.id, + label: menuKey, + commandId: option.commandId, + }); + }} + /> +
    + ); + } + return (
    - {selected && } + {selected && ( + + )} ); } @@ -393,14 +769,13 @@ function MobileSelectionOptionRow(props: { function useMobileSchemaInteraction(props: { schema: IMenuSchema; menuManagerService: IMenuManagerService; - menuType?: string; onOpenView: (view: MobileMenuView) => void; inheritedDisabled$?: Observable; }) { - const { schema, menuManagerService, menuType, onOpenView, inheritedDisabled$ } = props; + const { schema, menuManagerService, onOpenView, inheritedDisabled$ } = props; const localeService = useDependency(LocaleService); - const menuItem = schema.item as IDisplayMenuItem | undefined; - const selectorItem = menuItem as IDisplayMenuItem> | undefined; + const menuItem = schema.item; + const selectorItem = menuItem?.type === MenuItemType.BUTTON ? undefined : menuItem; const disabled$ = useMemo(() => { const sources = [inheritedDisabled$, menuItem?.disabled$].filter((source): source is Observable => Boolean(source)); return sources.length @@ -409,10 +784,17 @@ function useMobileSchemaInteraction(props: { }, [inheritedDisabled$, menuItem?.disabled$]); const disabled = useObservable(disabled$, false); const hidden = useObservable(menuItem?.hidden$, false); + const activated = useObservable(menuItem?.activated$, false); const value = useObservable(menuItem?.value$); const selectionsFromObservable = useObservable( selectorItem && isObservable(selectorItem.selections) ? selectorItem.selections : undefined ); + const schemaChildren = useMemo(() => schema.children ?? [], [schema.children]); + const visibleSchemaChildren$ = useMemo( + () => getVisibleSchemaCount$(schemaChildren), + [schemaChildren] + ); + const visibleSchemaChildren = useObservable(visibleSchemaChildren$, schemaChildren.length); const selections = useMemo(() => { if (!selectorItem || (menuItem?.type !== MenuItemType.SELECTOR && menuItem?.type !== MenuItemType.BUTTON_SELECTOR)) { @@ -438,20 +820,23 @@ function useMobileSchemaInteraction(props: { return null; } - const schemaChildren = schema.children ?? []; - const hasSubmenu = schemaChildren.length > 0 || selections.length > 0 || subMenuItems.length > 0; + const hasSubmenu = visibleSchemaChildren > 0 || selections.length > 0 || subMenuItems.length > 0; + const effectivelyDisabled = disabled || (menuItem.type === MenuItemType.SUBITEMS && !hasSubmenu); const currentValueText = typeof value === 'string' || typeof value === 'number' ? String(value) : ''; const onPress = (onExecute?: IBaseMenuProps['onOptionSelect']) => { - if (disabled) { + if (effectivelyDisabled) { return; } if (schemaChildren.length > 0) { + const schemas = menuItem.type === MenuItemType.BUTTON_SELECTOR && selections.length === 0 + ? [{ ...schema, children: undefined }, ...schemaChildren] + : schemaChildren; onOpenView({ kind: 'schema', title: getMenuSchemaTitle(schema, localeService), - schemas: schemaChildren, + schemas, disabled$, }); return; @@ -484,19 +869,19 @@ function useMobileSchemaInteraction(props: { return; } - const buttonItem = menuItem as IDisplayMenuItem>; onExecute?.({ - commandId: buttonItem.commandId, + commandId: menuItem.commandId, value, - id: buttonItem.id, + id: menuItem.id, label: schema.key, - params: buttonItem.params, + params: menuItem.params, }); }; return { menuItem, - disabled, + activated, + disabled: effectivelyDisabled, hidden, value, currentValueText, @@ -505,6 +890,16 @@ function useMobileSchemaInteraction(props: { }; } +function getVisibleSchemaCount$(schemas: IMenuSchema[]) { + const leafSchemas = getLeafItemSchemas(schemas); + if (leafSchemas.length === 0) { + return of(0); + } + + return combineLatest(leafSchemas.map((schema) => (schema.item?.hidden$ ?? of(false)).pipe(startWith(false)))) + .pipe(map((hiddenStates) => hiddenStates.filter((hidden) => !hidden).length)); +} + function getMenuSchemaTitle(schema: IMenuSchema, localeService: LocaleService) { const menuItem = schema.item as IDisplayMenuItem | undefined; @@ -516,6 +911,10 @@ function getMenuSchemaTitle(schema: IMenuSchema, localeService: LocaleService) { return localeService.t(menuItem.label); } + if (typeof menuItem?.tooltip === 'string') { + return localeService.t(menuItem.tooltip); + } + if (typeof schema.title === 'string') { return localeService.t(schema.title); } diff --git a/packages/ui/src/views/menu/mobile/MobileMenuDrawer.tsx b/packages/ui/src/views/menu/mobile/MobileMenuDrawer.tsx new file mode 100644 index 0000000000..834420562b --- /dev/null +++ b/packages/ui/src/views/menu/mobile/MobileMenuDrawer.tsx @@ -0,0 +1,143 @@ +/** + * 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 { ComponentProps } from 'react'; +import type { IMenuSchema } from '../../../services/menu/menu-manager.service'; +import type { MobileDrawerSnap } from '../../components/mobile-drawer/MobileDrawer'; +import { LocaleService } from '@univerjs/core'; +import { clsx, ConfigContext, resetButtonClassName } from '@univerjs/design'; +import { CloseIcon, MoreLeftIcon } from '@univerjs/icons'; +import { useContext, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { IMenuManagerService } from '../../../services/menu/menu-manager.service'; +import { useDependency, useObservable } from '../../../utils/di'; +import { MobileDrawer } from '../../components/mobile-drawer/MobileDrawer'; +import { MobileMenu } from './MobileMenu'; + +interface IMobileMenuDrawerProps { + visible: boolean; + title?: string; + menuType?: string; + schemas?: IMenuSchema[]; + menuManagerService?: IMenuManagerService; + onClose: () => void; + onOptionSelect?: ComponentProps['onOptionSelect']; +} + +export function MobileMenuDrawer(props: IMobileMenuDrawerProps) { + if (!props.visible) { + return null; + } + + return ; +} + +function MobileMenuDrawerContent(props: IMobileMenuDrawerProps) { + const { title, menuType, schemas, menuManagerService, onClose, onOptionSelect } = props; + const [navigation, setNavigation] = useState<{ title?: string; onBack: () => void } | null>(null); + const [drawerSnap, setDrawerSnap] = useState('compact'); + const localeService = useDependency(LocaleService); + const rootMenuManagerService = useDependency(IMenuManagerService); + const direction = useObservable(localeService.direction$); + const { mountContainer } = useContext(ConfigContext); + + if (!mountContainer) { + return null; + } + + return createPortal( +
    + + ) + :
    + )} + > + + +
    , + mountContainer + ); +} diff --git a/packages/ui/src/views/menu/mobile/__tests__/MobileMenu.spec.tsx b/packages/ui/src/views/menu/mobile/__tests__/MobileMenu.spec.tsx index 12b5f94187..90e5ff7641 100644 --- a/packages/ui/src/views/menu/mobile/__tests__/MobileMenu.spec.tsx +++ b/packages/ui/src/views/menu/mobile/__tests__/MobileMenu.spec.tsx @@ -14,57 +14,223 @@ * limitations under the License. */ -import type { ComponentProps, ComponentType } from 'react'; +import type { ComponentProps } from 'react'; import type { IMenuSchema } from '../../../../services/menu/menu-manager.service'; import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; -import { ILogService, Injector, LocaleService } from '@univerjs/core'; -import { BehaviorSubject, Subject } from 'rxjs'; +import { + ConfigService, + DesktopLogService, + IConfigService, + ILogService, + Injector, + LocaleService, + LocaleType, +} from '@univerjs/core'; +import { BehaviorSubject } from 'rxjs'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { ComponentManager, IconManager } from '../../../../common'; +import enUS from '../../../../locale/en-US'; import { MenuItemType } from '../../../../services/menu/menu'; -import { IMenuManagerService } from '../../../../services/menu/menu-manager.service'; -import { connectInjector } from '../../../../utils/di'; +import { IMenuManagerService, MenuManagerService } from '../../../../services/menu/menu-manager.service'; +import { RediProvider } from '../../../../utils/di'; import { MobileMenu } from '../MobileMenu'; -class TestLocaleService { - t(key: string) { - return key; - } -} - -class TestMenuManagerService { - readonly menuChanged$ = new Subject(); - - getMenuByPositionKey() { - return []; - } -} - -class TestLogService { - warn(): void {} -} - function renderWithDependencies( schemas: IMenuSchema[], - onOptionSelect: NonNullable['onOptionSelect']> + onOptionSelect: NonNullable['onOptionSelect']>, + props?: Pick, 'showHeader' | 'onNavigationChange' | 'presentation'> ) { const injector = new Injector(); - injector.add([LocaleService, { useClass: TestLocaleService as never }]); - injector.add([ILogService, { useClass: TestLogService as never }]); - injector.add([IMenuManagerService, { useClass: TestMenuManagerService as never }]); + injector.add([IConfigService, { useClass: ConfigService }]); + injector.add([LocaleService]); + injector.get(LocaleService).load({ [LocaleType.EN_US]: enUS }); + injector.get(LocaleService).setLocale(LocaleType.EN_US); + injector.add([ILogService, { useClass: DesktopLogService }]); + injector.add([IMenuManagerService, { useClass: MenuManagerService }]); injector.add([ComponentManager]); injector.add([IconManager]); + injector.get(ComponentManager).register('interactive-test-label', () => ); + injector.get(ComponentManager).register('interactive-button-label', (props: { value?: number; onChange?: (value: number) => void }) => ( + + )); + injector.get(ComponentManager).register('plain-custom-label', () => Plain custom action); - const ConnectedTestRoot = connectInjector(() => ( - - ), injector) as ComponentType; + const renderMenu = (nextSchemas: IMenuSchema[]) => ( + + + + ); + const result = render(renderMenu(schemas)); - return render(); + return { + ...result, + rerenderWithSchemas(nextSchemas: IMenuSchema[]) { + result.rerender(renderMenu(nextSchemas)); + }, + }; } afterEach(cleanup); describe('MobileMenu', () => { + it('renders grouped context menu items as a horizontal text-only bar', () => { + const onOptionSelect = vi.fn(); + renderWithDependencies([{ + key: 'quick', + order: 0, + children: [{ + key: 'copy', + order: 0, + item: { id: 'copy', type: MenuItemType.BUTTON, title: 'Copy' }, + }, { + key: 'clear', + order: 1, + item: { id: 'clear', type: MenuItemType.SUBITEMS, title: 'Clear' }, + children: [{ + key: 'clear.all', + order: 0, + item: { id: 'clear.all', type: MenuItemType.BUTTON, title: 'Clear all' }, + }], + }], + }], onOptionSelect, { presentation: 'context-bar' }); + + const bar = document.querySelector('[data-u-comp="mobile-context-menu-bar"]'); + const copy = screen.getByRole('menuitem', { name: 'Copy' }); + expect(bar).toBeTruthy(); + expect(copy.querySelector('svg')).toBeNull(); + expect(copy.className).toContain('univer-snap-start'); + expect(copy.className).toContain('univer-outline-none'); + + fireEvent.click(screen.getByRole('menuitem', { name: 'Clear' })); + fireEvent.click(screen.getByRole('menuitem', { name: 'Clear all' })); + expect(onOptionSelect).toHaveBeenCalledWith(expect.objectContaining({ id: 'clear.all' })); + }); + + it('uses the shared mobile touch target and dark theme surface', () => { + renderWithDependencies([ + { + key: 'insert', + order: 0, + item: { + id: 'insert.menu', + type: MenuItemType.BUTTON, + title: 'Insert', + }, + }, + ], vi.fn()); + + const menuItem = screen.getByRole('button', { name: 'Insert' }); + expect(menuItem.className).toContain('univer-min-h-12'); + expect(menuItem.className).toContain('dark:!univer-bg-gray-800'); + }); + + it('uses a toolbar tooltip as the mobile menu label', () => { + renderWithDependencies([ + { + key: 'image', + order: 0, + item: { + id: 'image.menu', + type: MenuItemType.SUBITEMS, + tooltip: 'Insert image', + }, + children: [ + { + key: 'image.float', + order: 0, + item: { + id: 'image.float', + type: MenuItemType.BUTTON, + title: 'Floating image', + }, + }, + ], + }, + ], vi.fn()); + + fireEvent.click(screen.getByRole('button', { name: 'Insert image' })); + expect(screen.getByText('Insert image')).toBeTruthy(); + expect(screen.getByRole('button', { name: 'Floating image' })).toBeTruthy(); + }); + + it('can delegate nested navigation to a drawer header', async () => { + const onNavigationChange = vi.fn(); + renderWithDependencies([ + { + key: 'image', + order: 0, + item: { id: 'image.menu', type: MenuItemType.SUBITEMS, title: 'Image' }, + children: [{ + key: 'image.float', + order: 0, + item: { id: 'image.float', type: MenuItemType.BUTTON, title: 'Floating image' }, + }], + }, + ], vi.fn(), { showHeader: false, onNavigationChange }); + + fireEvent.click(screen.getByRole('button', { name: 'Image' })); + await waitFor(() => expect(onNavigationChange).toHaveBeenLastCalledWith(expect.objectContaining({ title: 'Image' }))); + expect(screen.queryByText('Image')).toBeNull(); + expect(screen.getByRole('button', { name: 'Floating image' })).toBeTruthy(); + }); + + it('keeps an open child view when an equivalent schema array is recreated', () => { + const createSchemas = (): IMenuSchema[] => [{ + key: 'image', + order: 0, + item: { id: 'image.menu', type: MenuItemType.SUBITEMS, title: 'Image' }, + children: [{ + key: 'image.float', + order: 0, + item: { id: 'image.float', type: MenuItemType.BUTTON, title: 'Floating image' }, + }], + }]; + const rendered = renderWithDependencies(createSchemas(), vi.fn()); + + fireEvent.click(screen.getByRole('button', { name: 'Image' })); + rendered.rerenderWithSchemas(createSchemas()); + + expect(screen.getByRole('button', { name: 'Floating image' })).toBeTruthy(); + expect(screen.queryByRole('button', { name: 'Image' })).toBeNull(); + }); + + it('returns through every nested level using the delegated drawer back action', async () => { + let navigation: { title?: string; onBack: () => void } | null = null; + renderWithDependencies([{ + key: 'insert', + order: 0, + item: { id: 'insert.menu', type: MenuItemType.SUBITEMS, title: 'Insert' }, + children: [{ + key: 'rows', + order: 0, + item: { id: 'rows.menu', type: MenuItemType.SUBITEMS, title: 'Rows' }, + children: [{ + key: 'rows.above', + order: 0, + item: { id: 'rows.above', type: MenuItemType.BUTTON, title: 'Above' }, + }], + }], + }], vi.fn(), { + showHeader: false, + onNavigationChange: (nextNavigation) => { + navigation = nextNavigation; + }, + }); + + fireEvent.click(screen.getByRole('button', { name: 'Insert' })); + fireEvent.click(screen.getByRole('button', { name: 'Rows' })); + await waitFor(() => expect(screen.getByRole('button', { name: 'Above' })).toBeTruthy()); + + act(() => navigation?.onBack()); + await waitFor(() => expect(screen.getByRole('button', { name: 'Rows' })).toBeTruthy()); + expect(screen.queryByRole('button', { name: 'Above' })).toBeNull(); + + act(() => navigation?.onBack()); + await waitFor(() => expect(screen.getByRole('button', { name: 'Insert' })).toBeTruthy()); + }); + it('disables an open child view when its parent becomes disabled', async () => { const disabled$ = new BehaviorSubject(false); const onOptionSelect = vi.fn(); @@ -95,11 +261,252 @@ describe('MobileMenu', () => { fireEvent.click(screen.getByRole('button', { name: 'Print' })); const child = screen.getByRole('button', { name: 'Print layout' }); - expect((child as HTMLButtonElement).disabled).toBe(false); + expect(child.hasAttribute('disabled')).toBe(false); act(() => disabled$.next(true)); - await waitFor(() => expect((child as HTMLButtonElement).disabled).toBe(true)); + await waitFor(() => expect(child.hasAttribute('disabled')).toBe(true)); fireEvent.click(child); expect(onOptionSelect).not.toHaveBeenCalled(); }); + + it('disables a submenu when every child is hidden', async () => { + const childHidden$ = new BehaviorSubject(true); + + renderWithDependencies([{ + key: 'insert', + order: 0, + item: { id: 'insert.menu', type: MenuItemType.SUBITEMS, title: 'Insert' }, + children: [{ + key: 'insert.row', + order: 0, + item: { + id: 'insert.row', + type: MenuItemType.BUTTON, + title: 'Insert row', + hidden$: childHidden$, + }, + }], + }], vi.fn()); + + const insert = screen.getByRole('button', { name: 'Insert' }); + await waitFor(() => expect(insert.hasAttribute('disabled')).toBe(true)); + + act(() => childHidden$.next(false)); + await waitFor(() => expect(insert.hasAttribute('disabled')).toBe(false)); + }); + + it('keeps a button-selector action available beside its auxiliary submenu items', () => { + const activated$ = new BehaviorSubject(false); + const onOptionSelect = vi.fn(); + + renderWithDependencies([{ + key: 'filter', + order: 0, + item: { + id: 'filter.toggle', + type: MenuItemType.BUTTON_SELECTOR, + title: 'Filter', + activated$, + }, + children: [{ + key: 'filter.clear', + order: 0, + item: { id: 'filter.clear', type: MenuItemType.BUTTON, title: 'Clear filter' }, + }], + }], onOptionSelect); + + fireEvent.click(screen.getByRole('button', { name: 'Filter' })); + const toggle = screen.getByRole('button', { name: 'Filter' }); + expect(screen.getByRole('button', { name: 'Clear filter' })).toBeTruthy(); + + fireEvent.click(toggle); + expect(onOptionSelect).toHaveBeenCalledWith(expect.objectContaining({ id: 'filter.toggle' })); + + act(() => activated$.next(true)); + expect(toggle.getAttribute('aria-pressed')).toBe('true'); + }); + + it('executes every supported actionable menu type with its command metadata', () => { + const onOptionSelect = vi.fn(); + renderWithDependencies([ + { + key: 'button', + order: 0, + item: { + id: 'button.menu', + commandId: 'button.command', + type: MenuItemType.BUTTON, + title: 'Button', + params: { source: 'mobile' }, + }, + }, + { + key: 'toggle', + order: 1, + item: { + id: 'toggle.menu', + commandId: 'toggle.command', + type: MenuItemType.BUTTON_SELECTOR, + title: 'Toggle', + value$: new BehaviorSubject('on'), + }, + }, + { + key: 'selector', + order: 2, + item: { + id: 'selector.menu', + type: MenuItemType.SELECTOR, + title: 'Selector', + value$: new BehaviorSubject('first'), + selections: [ + { label: 'First', value: 'first', commandId: 'selector.command' }, + { label: 'Disabled', value: 'disabled', commandId: 'disabled.command', disabled: true }, + ], + }, + }, + { + key: 'submenu', + order: 3, + item: { id: 'submenu.menu', type: MenuItemType.SUBITEMS, title: 'Submenu' }, + children: [{ + key: 'nested', + order: 0, + item: { id: 'nested.menu', commandId: 'nested.command', type: MenuItemType.BUTTON, title: 'Nested' }, + }], + }, + ], onOptionSelect); + + fireEvent.click(screen.getByRole('button', { name: 'Button' })); + fireEvent.click(screen.getByRole('button', { name: /Toggle/ })); + fireEvent.click(screen.getByRole('button', { name: /Selector/ })); + expect(screen.getByRole('button', { name: 'First' }).getAttribute('aria-pressed')).toBe('true'); + fireEvent.click(screen.getByRole('button', { name: 'First' })); + fireEvent.click(screen.getByRole('button', { name: 'Disabled' })); + fireEvent.click(screen.getByRole('button', { name: 'Back' })); + fireEvent.click(screen.getByRole('button', { name: 'Submenu' })); + fireEvent.click(screen.getByRole('button', { name: 'Nested' })); + + expect(onOptionSelect.mock.calls.map(([option]) => option)).toEqual([ + expect.objectContaining({ id: 'button.menu', commandId: 'button.command', params: { source: 'mobile' } }), + expect.objectContaining({ id: 'toggle.menu', commandId: 'toggle.command', value: 'on' }), + expect.objectContaining({ id: 'selector.menu', commandId: 'selector.command', value: 'first' }), + expect.objectContaining({ id: 'nested.menu', commandId: 'nested.command' }), + ]); + }); + + it('does not wrap an interactive custom selector component in another button', () => { + const onOptionSelect = vi.fn(); + renderWithDependencies([{ + key: 'shape', + order: 0, + item: { + id: 'shape.menu', + type: MenuItemType.SELECTOR, + title: 'Shape', + value$: new BehaviorSubject('rectangle'), + selections: [{ + value: 'rectangle', + label: { name: 'interactive-test-label', props: {} }, + }], + }, + }], onOptionSelect); + + fireEvent.click(screen.getByRole('button', { name: /Shape/ })); + const nestedControl = screen.getByRole('button', { name: 'Nested control' }); + expect(nestedControl.parentElement?.closest('button')).toBeNull(); + expect(nestedControl.closest('.univer-overflow-y-auto')).toBeNull(); + fireEvent.click(nestedControl); + expect(onOptionSelect).not.toHaveBeenCalled(); + }); + + it('executes a custom selector when its component changes value', () => { + const onOptionSelect = vi.fn(); + renderWithDependencies([{ + key: 'color', + order: 0, + item: { + id: 'color.menu', + type: MenuItemType.SELECTOR, + title: 'Color', + selections: [{ + value: 1, + commandId: 'color.command', + label: { name: 'interactive-button-label', props: {} }, + }], + }, + }], onOptionSelect); + + fireEvent.click(screen.getByRole('button', { name: /Color/ })); + fireEvent.click(screen.getByRole('button', { name: 'Increase' })); + expect(onOptionSelect).toHaveBeenCalledWith(expect.objectContaining({ + id: 'color.menu', + commandId: 'color.command', + value: 2, + })); + }); + + it('keeps an unregistered object label as a clickable localized option', () => { + const onOptionSelect = vi.fn(); + renderWithDependencies([{ + key: 'conditional-formatting', + order: 0, + item: { + id: 'conditional-formatting.menu', + type: MenuItemType.SELECTOR, + title: 'Conditional formatting', + selections: [{ + value: 'create', + label: { name: 'Create rule', selectable: false }, + }], + }, + }], onOptionSelect); + + fireEvent.click(screen.getByRole('button', { name: /Conditional formatting/ })); + fireEvent.click(screen.getByRole('button', { name: 'Create rule' })); + expect(onOptionSelect).toHaveBeenCalledWith(expect.objectContaining({ + id: 'conditional-formatting.menu', + value: 'create', + })); + }); + + it('lets an interactive custom button component execute without nesting buttons', () => { + const onOptionSelect = vi.fn(); + renderWithDependencies([{ + key: 'insert.rows', + order: 0, + item: { + id: 'insert.rows', + commandId: 'insert.rows.command', + type: MenuItemType.BUTTON, + value$: new BehaviorSubject(2), + label: { name: 'interactive-button-label', props: {} }, + }, + }], onOptionSelect); + + const increase = screen.getByRole('button', { name: 'Increase' }); + expect(increase.parentElement?.closest('button')).toBeNull(); + fireEvent.click(increase); + expect(onOptionSelect).toHaveBeenCalledWith(expect.objectContaining({ + id: 'insert.rows', + commandId: 'insert.rows.command', + value: 3, + })); + }); + + it('keeps a plain custom button label clickable', () => { + const onOptionSelect = vi.fn(); + renderWithDependencies([{ + key: 'freeze.selection', + order: 0, + item: { + id: 'freeze.selection', + type: MenuItemType.BUTTON, + label: { name: 'plain-custom-label', props: {} }, + }, + }], onOptionSelect); + + fireEvent.click(screen.getByText('Plain custom action')); + expect(onOptionSelect).toHaveBeenCalledWith(expect.objectContaining({ id: 'freeze.selection' })); + }); }); diff --git a/packages/ui/src/views/mobile-workbench/MobileWorkbench.tsx b/packages/ui/src/views/mobile-workbench/MobileWorkbench.tsx index 18444dae84..72c1d15099 100644 --- a/packages/ui/src/views/mobile-workbench/MobileWorkbench.tsx +++ b/packages/ui/src/views/mobile-workbench/MobileWorkbench.tsx @@ -27,7 +27,7 @@ import { IWorkbenchService } from '../../services/workbench/workbench.service'; import { connectInjector, useDependency, useObservable } from '../../utils/di'; import { ComponentContainer, useComponentsOfPart } from '../components/ComponentContainer'; import { MobileContextMenu } from '../components/context-menu/MobileContextMenu'; -import { Sidebar } from '../components/sidebar/Sidebar'; +import { MobileSidebar } from '../components/sidebar/MobileSidebar'; import { WorkbenchSkeleton } from '../components/workbench-skeleton/WorkbenchSkeleton'; export interface IUniverAppProps extends IWorkbenchOptions { @@ -73,6 +73,7 @@ export function MobileWorkbench(props: IUniverAppProps) { const themeSwitcherService = useDependency(ThemeSwitcherService); const contentRef = useRef(null); + const viewportRef = useRef(null); const footerComponents = useComponentsOfPart(BuiltInUIPart.FOOTER); const headerComponents = useComponentsOfPart(BuiltInUIPart.HEADER); @@ -126,14 +127,74 @@ export function MobileWorkbench(props: IUniverAppProps) { portalContainer.dir = direction; }, [direction, portalContainer]); + useLayoutEffect(() => { + document.documentElement.classList.toggle('univer-dark', darkMode); + }, [darkMode]); + + useEffect(() => { + const visualViewport = window.visualViewport; + const viewportElement = viewportRef.current; + if (!viewportElement) { + return undefined; + } + + let stableHeight = Math.round(mountContainer.getBoundingClientRect().height || window.innerHeight); + let stableWidth = Math.round(mountContainer.getBoundingClientRect().width || window.innerWidth); + + const updateKeyboardInset = () => { + const visibleBottom = visualViewport + ? visualViewport.offsetTop + visualViewport.height + : window.innerHeight; + viewportElement.style.setProperty( + '--univer-mobile-keyboard-inset', + `${Math.max(0, Math.round(stableHeight - visibleBottom))}px` + ); + }; + const updateStableViewport = () => { + const width = Math.round(mountContainer.getBoundingClientRect().width || window.innerWidth); + const activeElement = document.activeElement; + const editing = activeElement instanceof HTMLInputElement || + activeElement instanceof HTMLTextAreaElement || + activeElement?.getAttribute('contenteditable') === 'true'; + + if (!editing || width !== stableWidth) { + stableHeight = Math.round(mountContainer.getBoundingClientRect().height || window.innerHeight); + stableWidth = width; + viewportElement.style.height = `${stableHeight}px`; + } + updateKeyboardInset(); + }; + + viewportElement.style.height = `${stableHeight}px`; + updateKeyboardInset(); + window.addEventListener('resize', updateStableViewport); + visualViewport?.addEventListener('resize', updateKeyboardInset); + visualViewport?.addEventListener('scroll', updateKeyboardInset); + + return () => { + window.removeEventListener('resize', updateStableViewport); + visualViewport?.removeEventListener('resize', updateKeyboardInset); + visualViewport?.removeEventListener('scroll', updateKeyboardInset); + }; + }, [mountContainer]); + + // Keep the inner layout focusable so focusin bubbles from editors and the layout service can refocus input. return ( - -
    - {/** - * IMPORTANT! This `tabIndex` should not be moved. This attribute allows the element to catch - * all focusin event merged from its descendants. The DesktopLayoutService would listen to focusin events - * bubbled to this element and refocus the input element. - */} + +
    - +
    {/* footer */} @@ -215,9 +274,16 @@ export function MobileWorkbench(props: IUniverAppProps) { )}
    -
    +
    {contextMenu && } +
    );