feat(sheets-ui): improve mobile editing interactions (#7605)

This commit is contained in:
Univer
2026-08-30 00:03:26 +08:00
committed by GitHub
parent 524a9aa357
commit 9f65fd63e7
200 changed files with 10685 additions and 933 deletions
@@ -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'), {
@@ -76,6 +76,9 @@ export function createTailwindContent(configUrl: string, options: ITailwindConte
const config: Omit<Config, 'content'> = {
prefix: 'univer-',
darkMode: 'selector',
future: {
hoverOnlyWhenSupported: true,
},
corePlugins: {
preflight: false,
},
@@ -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<HTMLDivElement>;
export function ActionRow({ className, ...props }: IActionRowProps) {
const { mobile } = useContext(ConfigContext);
return (
<div
className={clsx(className, mobile && `
univer-flex univer-w-full univer-justify-stretch univer-gap-3
[&>button]:!univer-m-0 [&>button]:!univer-h-12 [&>button]:!univer-min-w-0 [&>button]:!univer-flex-1
[&>button]:!univer-rounded-xl
`)}
{...props}
/>
);
}
@@ -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(<ActionRow><button type="button">Save</button></ActionRow>);
expect(desktop.container.firstElementChild?.className).not.toContain('[&>button]:!univer-h-12');
desktop.unmount();
const mobile = render(
<ConfigProvider mobile mountContainer={document.body}>
<ActionRow><button type="button">Save</button></ActionRow>
</ConfigProvider>
);
expect(mobile.container.firstElementChild?.className).toContain('[&>button]:!univer-h-12');
});
});
@@ -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()}
>
<MemoizedColorPresets
hsv={hsv}
onChange={(h, s, v) => {
handleColorChange(h, s, v);
handleAlphaChange(1);
handleColorChanged(h, s, v, 1);
}}
/>
{mobile
? (
<MemoizedMobileColorPresets
value={hsvToHex(...hsv)}
onSelect={(color) => {
const [h, s, v] = hexToHsv(color);
handleColorChange(h, s, v);
handleAlphaChange(1);
handleColorChanged(h, s, v, 1);
}}
/>
)
: (
<MemoizedColorPresets
hsv={hsv}
onChange={(h, s, v) => {
handleColorChange(h, s, v);
handleAlphaChange(1);
handleColorChanged(h, s, v, 1);
}}
/>
)}
<div className="univer-flex univer-h-7 univer-items-center">
<a
className={`
univer-cursor-pointer univer-gap-2 univer-text-sm univer-text-gray-900 univer-transition-opacity
hover:univer-opacity-80
<div className={clsx('univer-flex univer-items-center', mobile ? 'univer-h-12' : 'univer-h-7')}>
<button
type="button"
className={clsx(`
univer-cursor-pointer univer-border-0 univer-text-sm univer-text-gray-900
dark:!univer-text-gray-0
`}
`, mobile
? `
univer-h-11 univer-w-full univer-rounded-xl univer-bg-gray-100 univer-font-medium
active:univer-bg-gray-200
dark:!univer-bg-gray-800
dark:active:!univer-bg-gray-700
`
: `
univer-bg-transparent univer-p-0 univer-transition-opacity
hover:univer-opacity-80
`)}
onClick={() => setVisible(true)}
>
{locale?.ColorPicker.more}
</a>
</button>
</div>
<Dialog
@@ -0,0 +1,66 @@
/**
* 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 { useContext, useState } from 'react';
import { Button } from '../button/Button';
import { ConfigContext } from '../config-provider/ConfigProvider';
import { hexToHsv, hsvToHex } from './color-conversion';
import { ColorInput } from './ColorInput';
import { ColorSpectrum } from './ColorSpectrum';
import { HueSlider } from './HueSlider';
export interface IColorPickerPanelProps {
value?: string;
confirmText?: string;
onConfirm?: (value: string) => 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 (
<div data-u-comp="color-picker-panel" className="univer-grid univer-gap-4">
<div className="univer-h-44 univer-overflow-hidden univer-rounded-lg">
<ColorSpectrum hsv={hsv} onChange={(h, s, v) => setHsv([h, s, v])} />
</div>
<div className="univer-flex univer-items-center univer-gap-3">
<span
className="
univer-size-10 univer-shrink-0 univer-rounded-lg univer-border univer-border-solid
univer-border-gray-200
dark:!univer-border-gray-600
"
style={{ backgroundColor: color }}
/>
<div className="univer-flex-1">
<HueSlider hsv={hsv} onChange={(h, s, v) => setHsv([h, s, v])} />
</div>
</div>
<ColorInput
hsv={hsv}
alpha={1}
format="hex"
onChange={(h, s, v) => setHsv([h, s, v])}
/>
<Button className="!univer-h-11 !univer-w-full" variant="primary" onClick={() => onConfirm?.(color)}>
{confirmText ?? locale?.ColorPicker.confirm}
</Button>
</div>
);
}
@@ -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 (
<div
className="univer-grid univer-gap-2"
style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(40px, 1fr))' }}
>
{colorPresets.flat().map((color) => (
<button
key={color}
type="button"
aria-label={color}
aria-pressed={color.toUpperCase() === value.toUpperCase()}
className={clsx(`
univer-box-border univer-flex univer-size-10 univer-cursor-pointer univer-items-center
univer-justify-center univer-justify-self-center univer-rounded-lg univer-border
univer-border-solid univer-border-transparent univer-bg-transparent univer-transition-colors
active:univer-bg-gray-100
dark:active:!univer-bg-gray-700
`, {
'univer-ring-2 univer-ring-primary-600 univer-ring-offset-2 univer-ring-offset-gray-0 dark:!univer-ring-primary-400 dark:!univer-ring-offset-gray-800': color.toUpperCase() === value.toUpperCase(),
})}
onClick={() => onSelect(color)}
>
<span
className={clsx(`
univer-aspect-square univer-w-8 univer-shrink-0 univer-rounded-md univer-border
univer-border-solid univer-border-transparent
`, {
'!univer-border-gray-200 dark:!univer-border-gray-600': color === '#FFFFFF',
})}
style={{ backgroundColor: color }}
/>
</button>
))}
</div>
);
}
@@ -24,6 +24,14 @@ afterEach(() => {
cleanup();
});
function getMoreColorButton(container: HTMLElement): HTMLButtonElement {
const button = Array.from(
container.querySelectorAll<HTMLButtonElement>('[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', () => {
<ColorPicker format="rgba" value="rgba(10, 20, 30, 0.5)" onChange={onChange} />
);
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(<ColorPicker format="hex" value="#00ff00" onChange={onChange} />);
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);
@@ -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<HTMLButtonElement>('[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', () => {
<ColorPicker />
</ConfigProvider>
);
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(<ColorPicker />);
const moreLink = container.querySelector('[data-u-comp="color-picker"] a') as HTMLAnchorElement;
const { container } = render(
<ConfigProvider locale={enUS.design} mountContainer={document.body}>
<ColorPicker />
</ConfigProvider>
);
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(<ColorPicker onChange={handleChange} />);
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(<MobileColorPresets value="#FFFFFF" onSelect={onSelect} />);
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(
<ConfigProvider locale={enUS.design} mountContainer={document.body}>
<ColorPickerPanel value="#3F83F8" onConfirm={onConfirm} />
</ConfigProvider>
);
expect(onConfirm).not.toHaveBeenCalled();
fireEvent.click(getByRole('button', { name: enUS.design.ColorPicker.confirm }));
expect(onConfirm).toHaveBeenCalledWith('#3f83f8');
});
});
describe('HueSlider', () => {
const onChange = vi.fn();
@@ -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<Omit<IConfigProviderProps, 'children'>>({
@@ -31,15 +35,20 @@ export const ConfigContext = createContext<Omit<IConfigProviderProps, 'children'
});
export function ConfigProvider(props: IConfigProviderProps) {
const { children, locale, mountContainer, direction } = props;
const { children, locale, mountContainer, direction, disableTooltips, mobile } = props;
const parentConfig = useContext(ConfigContext);
const resolvedDisableTooltips = disableTooltips ?? parentConfig.disableTooltips;
const resolvedMobile = mobile ?? parentConfig.mobile;
const value = useMemo(() => {
return {
locale,
direction,
mountContainer,
disableTooltips: resolvedDisableTooltips,
mobile: resolvedMobile,
};
}, [locale, direction, mountContainer]);
}, [locale, direction, mountContainer, resolvedDisableTooltips, resolvedMobile]);
return (
<ConfigContext.Provider value={value}>
@@ -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 (
<DialogProvider
open={open}
onOpenChange={handleOpenChange}
modal={mask !== false}
>
<DialogContent
<Content
ref={handleContentRef}
className={clsx(className, {
'!univer-animate-none': draggable,
@@ -382,7 +385,7 @@ export function Dialog(props: IDialogProps) {
{footer}
</DialogFooter>
)}
</DialogContent>
</Content>
</DialogProvider>
);
}
@@ -48,7 +48,7 @@ const DialogOverlay = forwardRef<
));
DialogOverlay.displayName = Overlay.displayName;
interface IDialogContentProps {
export interface IDialogContentProps {
closable?: boolean;
onClickClose?: () => void;
mountContainer?: HTMLElement | null;
@@ -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<ComponentRef<typeof DialogContent>, ComponentProps<typeof DialogContent>>((props, ref) => {
const { className, style, ...rest } = props;
return (
<DialogContent
{...rest}
ref={ref}
className={clsx(`
!univer-bottom-0 !univer-left-0 !univer-right-0 !univer-top-auto !univer-max-h-[80dvh] !univer-max-w-none
!univer-translate-x-0 !univer-translate-y-0 !univer-gap-4 !univer-overflow-y-auto !univer-rounded-t-2xl
!univer-p-4
[&_[data-slot='dialog-footer']]:!univer-flex-row [&_[data-slot='dialog-footer']]:!univer-gap-3
[&_[data-slot='dialog-footer']_button]:!univer-h-12 [&_[data-slot='dialog-footer']_button]:!univer-flex-1
[&_button[data-slot='close']]:!univer-right-3 [&_button[data-slot='close']]:!univer-top-3
[&_button[data-slot='close']]:!univer-size-10
`, className)}
style={{
...style,
position: 'fixed',
insetInline: 0,
top: 'auto',
bottom: 0,
width: '100%',
maxWidth: 'none',
margin: 0,
transform: 'none',
}}
/>
);
});
@@ -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(
<DialogProvider open>
<MobileDialogContent>
<DialogTitle>Mobile dialog</DialogTitle>
</MobileDialogContent>
</DialogProvider>
);
const dialog = screen.getByRole('dialog');
expect(dialog).toHaveStyle({ bottom: '0px', position: 'fixed', width: '100%' });
expect(dialog.className).toContain('!univer-rounded-t-2xl');
});
});
@@ -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<typeof DropdownMenuContent> {
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 (
<MobileDropdownMenu
open={open}
disabled={disabled}
items={items}
onOpenChange={handleChangeOpen}
>
{children}
</MobileDropdownMenu>
);
}
return (
<DropdownMenuPrimitive modal={false} open={open} onOpenChange={handleChangeOpen}>
<DropdownMenuTrigger asChild>
@@ -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 (
<div
key={index}
className="
univer-my-1 univer-h-px univer-bg-gray-200
dark:!univer-bg-gray-700
"
/>
);
}
if (item.type === 'custom') {
return <div key={index} className={item.className}>{item.children}</div>;
}
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 (
<button
key={value}
type="button"
disabled={option.disabled}
className={mobileRowClassName(value === item.value)}
onClick={() => {
item.onSelect?.(value);
onOpenChange(false);
}}
>
{option.label}
</button>
);
});
}
if (item.type === 'checkbox') {
return (
<button
key={index}
type="button"
disabled={item.disabled}
className={clsx(mobileRowClassName(Boolean(item.checked)), item.className)}
onClick={() => {
item.onSelect?.(item.value);
onOpenChange(false);
}}
>
{item.label}
</button>
);
}
if (item.type === 'item') {
return (
<button
key={index}
type="button"
disabled={item.disabled}
className={clsx(mobileRowClassName(false), item.className, {
'!univer-text-red-600 dark:!univer-text-red-400': item.variant === 'destructive',
})}
onClick={() => {
item.onSelect?.(item);
onOpenChange(false);
}}
>
{item.children}
</button>
);
}
return (
<div key={index} className="univer-flex univer-flex-col univer-gap-2">
<div className="univer-px-4 univer-py-2 univer-text-sm univer-font-medium univer-text-gray-500">
{item.children}
</div>
{item.options?.map(renderMenuItem)}
</div>
);
}
return (
<MobileDropdownSurface
open={open}
disabled={disabled}
onOpenChange={onOpenChange}
content={<div className="univer-flex univer-flex-col univer-gap-2">{items.map(renderMenuItem)}</div>}
>
{children}
</MobileDropdownSurface>
);
}
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
`);
}
@@ -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(
<ConfigProvider mountContainer={document.body} mobile>
<DropdownMenu
open
items={[{ type: 'item', children: 'Mobile item', onSelect }]}
>
<button type="button">Trigger</button>
</DropdownMenu>
</ConfigProvider>
);
const item = getByText('Mobile item');
expect(item).toHaveClass('univer-w-full');
fireEvent.click(item);
expect(onSelect).toHaveBeenCalledTimes(1);
});
});
@@ -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<typeof PopoverContent> {
@@ -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 (
<MobileDropdownSurface
open={open}
disabled={disabled}
onOpenChange={handleChangeOpen}
content={overlay}
>
{children}
</MobileDropdownSurface>
);
}
return (
<PopoverPrimitive open={open} onOpenChange={handleChangeOpen}>
<PopoverTrigger asChild>
@@ -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 (
<Dialog open={open} onOpenChange={onOpenChange} modal>
<DialogTrigger asChild disabled={disabled}>
{children}
</DialogTrigger>
<DialogContent
mountContainer={mountContainer}
overlayClassName="!univer-z-[1390]"
className="
!univer-bottom-0 !univer-left-0 !univer-right-0 !univer-top-auto !univer-z-[1400] !univer-block
!univer-max-h-[80dvh] !univer-w-full !univer-max-w-none !univer-translate-x-0 !univer-translate-y-0
!univer-overflow-y-auto !univer-rounded-t-2xl !univer-border-0 !univer-bg-gray-50 !univer-p-4
!univer-pt-14
dark:!univer-bg-gray-900
[&_button[data-slot='close']]:!univer-right-3 [&_button[data-slot='close']]:!univer-top-3
[&_button[data-slot='close']]:!univer-size-10
[&_button]:!univer-min-h-11
"
style={{
position: 'fixed',
insetInline: 0,
top: 'auto',
bottom: 0,
width: '100%',
maxWidth: 'none',
transform: 'none',
}}
>
<div
aria-hidden="true"
className="
univer-absolute univer-left-1/2 univer-top-3 univer-h-1 univer-w-10 -univer-translate-x-1/2
univer-rounded-full univer-bg-gray-300
dark:!univer-bg-gray-600
"
/>
<DialogTitle className="univer-sr-only">{locale?.Accessibility.menu}</DialogTitle>
<DialogDescription className="univer-hidden" />
{content}
</DialogContent>
</Dialog>
);
}
@@ -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', () => {
<button type="button">Trigger</button>
</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', () => {
<button type="button">Trigger</button>
</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(
<ConfigProvider locale={enUS.design} mountContainer={document.body} mobile>
<Dropdown overlay={<div>Overlay Content</div>} open>
<button type="button">Trigger</button>
</Dropdown>
</ConfigProvider>
);
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();
});
});
@@ -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<ButtonHTMLAttributes<HTMLButtonElement>, '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 (
<button
type={type}
className={clsx(resetButtonClassName, `
univer-flex univer-min-h-12 univer-w-full univer-items-center univer-gap-3 univer-rounded-xl univer-px-4
univer-text-left univer-text-base univer-font-medium univer-text-gray-900
disabled:univer-opacity-40
dark:!univer-text-gray-100
[&>svg]:univer-size-5
`, {
'univer-bg-gray-0 active:univer-bg-gray-100 dark:!univer-bg-gray-800 dark:active:!univer-bg-gray-700': variant === 'surface',
'univer-bg-gray-100 active:univer-bg-gray-200 dark:!univer-bg-gray-800 dark:active:!univer-bg-gray-700': variant === 'subtle',
}, bordered && borderBottomClassName, className)}
{...buttonProps}
>
{icon}
<span className="univer-flex-1">{title}</span>
{value && valueType === 'color' && (
<span
className="
univer-size-6 univer-rounded-md univer-border univer-border-solid univer-border-gray-200
dark:!univer-border-gray-600
"
style={{ backgroundColor: value }}
/>
)}
{value && valueType === 'text' && (
<span
className="
univer-max-w-32 univer-truncate univer-text-sm univer-font-normal univer-text-gray-500
dark:!univer-text-gray-400
"
>
{value}
</span>
)}
{trailing}
</button>
);
}
@@ -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(<MobileActionRow title="Rename" aria-label="Rename" onClick={onClick} />);
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();
});
});
@@ -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<HTMLElement | null>(null);
const tooltipRef = useRef<HTMLDivElement | null>(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 {
@@ -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(
<ConfigProvider mountContainer={document.body} disableTooltips>
<ConfigProvider mountContainer={document.body}>
<Tooltip title="Disabled tip" visible={false} onVisibleChange={onVisibleChange}>
Trigger
</Tooltip>
</ConfigProvider>
</ConfigProvider>
);
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(
<Tooltip title="From button" asChild={false}>
+8
View File
@@ -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';
+1
View File
@@ -21,6 +21,7 @@ const locale: typeof enUS = {
Accessibility: {
closeBadge: 'إغلاق الشارة',
close: 'إغلاق',
menu: 'القائمة',
previous: 'السابق',
next: 'التالي',
imageGallery: 'معرض الصور',
+1
View File
@@ -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 dimatges',
+1
View File
@@ -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',
+1
View File
@@ -19,6 +19,7 @@ const locale = {
Accessibility: {
closeBadge: 'Close badge',
close: 'Close',
menu: 'Menu',
previous: 'Previous',
next: 'Next',
imageGallery: 'Image gallery',
+1
View File
@@ -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',
+1
View File
@@ -21,6 +21,7 @@ const locale: typeof enUS = {
Accessibility: {
closeBadge: 'بستن نشان',
close: 'بستن',
menu: 'منو',
previous: 'قبلی',
next: 'بعدی',
imageGallery: 'گالری تصاویر',
+1
View File
@@ -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 dimages',
+1
View File
@@ -21,6 +21,7 @@ const locale: typeof enUS = {
Accessibility: {
closeBadge: 'Tutup lencana',
close: 'Tutup',
menu: 'Menu',
previous: 'Sebelumnya',
next: 'Berikutnya',
imageGallery: 'Galeri gambar',
+1
View File
@@ -21,6 +21,7 @@ const locale: typeof enUS = {
Accessibility: {
closeBadge: 'Chiudi badge',
close: 'Chiudi',
menu: 'Menu',
previous: 'Precedente',
next: 'Successivo',
imageGallery: 'Galleria immagini',
+1
View File
@@ -21,6 +21,7 @@ const locale: typeof enUS = {
Accessibility: {
closeBadge: 'バッジを閉じる',
close: '閉じる',
menu: 'メニュー',
previous: '前へ',
next: '次へ',
imageGallery: '画像ギャラリー',
+1
View File
@@ -21,6 +21,7 @@ const locale: typeof enUS = {
Accessibility: {
closeBadge: '배지 닫기',
close: '닫기',
menu: '메뉴',
previous: '이전',
next: '다음',
imageGallery: '이미지 갤러리',
+1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -21,6 +21,7 @@ const locale: typeof enUS = {
Accessibility: {
closeBadge: 'Закрыть метку',
close: 'Закрыть',
menu: 'Меню',
previous: 'Предыдущее',
next: 'Следующее',
imageGallery: 'Галерея изображений',
+1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -21,6 +21,7 @@ const locale: typeof enUS = {
Accessibility: {
closeBadge: '关闭徽章',
close: '关闭',
menu: '菜单',
previous: '上一个',
next: '下一个',
imageGallery: '图片库',
+1
View File
@@ -21,6 +21,7 @@ const locale: typeof enUS = {
Accessibility: {
closeBadge: '關閉徽章',
close: '關閉',
menu: '選單',
previous: '上一個',
next: '下一個',
imageGallery: '圖片庫',
+1
View File
@@ -21,6 +21,7 @@ const locale: typeof enUS = {
Accessibility: {
closeBadge: '關閉徽章',
close: '關閉',
menu: '選單',
previous: '上一個',
next: '下一個',
imageGallery: '圖片庫',
+2
View File
@@ -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,
@@ -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 | null> | ((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);
@@ -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 (
<div
data-u-comp="mobile-rich-text-toolbar"
className={clsx(`
univer-box-border univer-bg-gray-0 univer-shadow-[0_1px_0_rgba(0,0,0,0.06)]
dark:!univer-bg-gray-800
`, className)}
onPointerDown={(event) => {
event.preventDefault();
event.stopPropagation();
}}
>
{!colorsVisible
? (
<div className="univer-grid univer-h-11 univer-grid-cols-7 univer-gap-1 univer-px-2">
<FormatButton label={localeService.t<LocaleKey>('docs-ui.toolbar.bold')} active={state.bold} icon={<BoldIcon />} onClick={() => execute(SetInlineFormatBoldCommand.id)} />
<FormatButton label={localeService.t<LocaleKey>('docs-ui.toolbar.italic')} active={state.italic} icon={<ItalicIcon />} onClick={() => execute(SetInlineFormatItalicCommand.id)} />
<FormatButton label={localeService.t<LocaleKey>('docs-ui.toolbar.underline')} active={state.underline} icon={<UnderlineIcon />} onClick={() => execute(SetInlineFormatUnderlineCommand.id)} />
<FormatButton label={localeService.t<LocaleKey>('docs-ui.toolbar.strikethrough')} active={state.strike} icon={<StrikethroughIcon />} onClick={() => execute(SetInlineFormatStrikethroughCommand.id)} />
<button
type="button"
aria-label={localeService.t<LocaleKey>('docs-ui.toolbar.textColor.main')}
className={clsx(resetButtonClassName, mobileButtonClassName)}
onClick={() => setColorsVisible(true)}
>
<FontColorDoubleIcon className="univer-size-5" extend={{ colorChannel1: state.color }} />
</button>
<FormatButton label={localeService.t<LocaleKey>('docs-ui.toolbar.unorder')} active={state.bulletList} icon={<UnorderIcon />} onClick={() => execute(BulletListCommand.id)} />
<FormatButton label={localeService.t<LocaleKey>('docs-ui.toolbar.order')} active={state.orderList} icon={<OrderIcon />} onClick={() => execute(OrderListCommand.id)} />
</div>
)
: (
<div
className="
univer-grid univer-h-11 univer-grid-cols-8 univer-items-center univer-gap-1 univer-px-2
"
>
<button
type="button"
aria-label={localeService.t<LocaleKey>('docs-ui.toolbar.textColor.main')}
className={clsx(resetButtonClassName, mobileButtonClassName, 'univer-size-9')}
onClick={() => setColorsVisible(false)}
>
<MoreLeftIcon className="univer-size-5" />
</button>
<button
type="button"
aria-label={localeService.t<LocaleKey>('docs-ui.toolbar.resetColor')}
className={clsx(resetButtonClassName, `
univer-flex univer-size-7 univer-items-center univer-justify-center
univer-justify-self-center univer-rounded-md univer-bg-gray-100 univer-text-xs
univer-text-gray-600
active:univer-scale-95
dark:!univer-bg-gray-700 dark:!univer-text-gray-200
`)}
onClick={() => execute(ResetInlineFormatTextColorCommand.id)}
>
<NoColorDoubleIcon className="univer-size-4" />
</button>
{colors.map((color) => (
<button
key={color}
type="button"
aria-label={`${localeService.t<LocaleKey>('docs-ui.toolbar.textColor.main')} ${color}`}
aria-pressed={state.color.toLowerCase() === color.toLowerCase()}
className={clsx(resetButtonClassName, `
univer-size-7 univer-justify-self-center univer-rounded-md univer-border-2
univer-border-solid
active:univer-scale-95
`, state.color.toLowerCase() === color.toLowerCase()
? 'univer-border-primary-500'
: 'univer-border-transparent')}
style={{ backgroundColor: color }}
onClick={() => execute(SetInlineFormatTextColorCommand.id, { value: color })}
/>
))}
<label
aria-label={localeService.t<LocaleKey>('docs-ui.toolbar.textColor.main')}
className={clsx(resetButtonClassName, `
univer-relative univer-flex univer-size-7 univer-items-center univer-justify-center
univer-justify-self-center univer-overflow-hidden univer-rounded-md univer-border-2
univer-border-solid univer-border-transparent
active:univer-scale-95
`)}
>
<ColorWheelMultiIcon className="univer-size-6" />
<input
aria-label={localeService.t<LocaleKey>('docs-ui.toolbar.textColor.main')}
className="
univer-absolute univer-inset-0 univer-size-full univer-cursor-pointer univer-opacity-0
"
type="color"
defaultValue={defaultTextColor}
onChange={(event) => {
execute(SetInlineFormatTextColorCommand.id, { value: event.target.value });
}}
/>
</label>
</div>
)}
</div>
);
}
function FormatButton(props: {
label: string;
active: boolean;
icon: ReactNode;
onClick: () => void;
}) {
return (
<button
type="button"
aria-label={props.label}
aria-pressed={props.active}
className={clsx(resetButtonClassName, mobileButtonClassName, props.active && activeButtonClassName)}
onClick={props.onClick}
>
<span className="univer-flex univer-size-5 univer-items-center univer-justify-center">{props.icon}</span>
</button>
);
}
function resolveToolbarState(editor: Readonly<Nullable<Editor>>, 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))),
};
}
@@ -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<typeof import('@univerjs/ui')>(),
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<HTMLDivElement>,
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<HTMLDivElement>,
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();
});
});
@@ -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();
};
}
@@ -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: () => <span /> });
commandService = injector.get(ICommandService);
@@ -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 <DocChartFloatingToolbar menuItems={menuItems} />;
}
if (mobile) {
return (
<MobileImagePopupMenu
menuItems={menuItems}
getLabel={(item) => 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);
};
@@ -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 (
<div className="univer-flex univer-flex-col univer-gap-2">
{menuItems.map((item) => {
const label = getLabel(item);
return (
<MobileActionRow
key={`${item.commandId}-${item.label}`}
title={label}
aria-label={label}
variant="subtle"
disabled={item.disable}
onClick={() => onSelect(item)}
/>
);
})}
</div>
);
}
@@ -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() {
@@ -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);
@@ -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 }]);
@@ -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: {},
});
});
});
@@ -205,6 +205,7 @@ export function openDataValidationMenuFactory(): IMenuItem<LocaleKey> {
id: OpenValidationPanelOperation.id,
title: 'sheets-data-validation-ui.panel.title',
type: MenuItemType.BUTTON,
params: {},
};
}
@@ -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
</Checkbox>
</FormLayout>
<DataValidationOptions value={options} onChange={handleUpdateRuleOptions} extraComponent={validator.optionsInput} />
<div className="univer-mt-5 univer-flex univer-flex-row univer-justify-end">
<ActionRow className="univer-mt-5 univer-flex univer-flex-row univer-justify-end">
<Button className="univer-ml-3" onClick={handleDelete}>
{localeService.t<LocaleKey>('sheets-data-validation-ui.panel.removeRule')}
</Button>
<Button className="univer-ml-3" variant="primary" onClick={handleOk}>
{localeService.t<LocaleKey>('sheets-data-validation-ui.panel.done')}
</Button>
</div>
</ActionRow>
</div>
);
}
@@ -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}
/>
))}
<div className="univer-mt-4 univer-flex univer-flex-row univer-justify-end univer-gap-2">
<ActionRow
className="univer-mt-4 univer-flex univer-flex-row univer-justify-end univer-gap-2"
>
{(rules.length && !hasDisableRule)
? (
<Button onClick={handleRemoveAll}>
@@ -127,7 +128,7 @@ export function DataValidationList(props: { workbook: Workbook }) {
<Button variant="primary" onClick={handleAddRule}>
{localeService.t<LocaleKey>('sheets-data-validation-ui.panel.add')}
</Button>
</div>
</ActionRow>
</div>
);
};
@@ -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<TestEditorService>('univer.editor.service');
const IDescriptionService = createIdentifier<TestDescriptionService>('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 }],
@@ -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<void>();
const clearControl$ = new Subject<void>();
const changing$ = new Subject<void>();
const contextChanged$ = new Subject<Record<string, boolean>>();
const imageIoChange$ = new Subject<number>();
const currentWorkbook$ = new Subject<never>();
const disposedWorkbook$ = new Subject<never>();
const dialogs$ = new Subject<never>();
const imageObject = { oKey: 'image-1' };
let attachedPopup: { extraProps?: Record<string, unknown> } | undefined;
const attachPopupToObject = vi.fn((_targetObject: unknown, popup: { extraProps?: Record<string, unknown> }) => {
@@ -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]);
@@ -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<string>();
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 [
{
@@ -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);
}
}
@@ -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 {
@@ -75,6 +75,15 @@ const locale: typeof enUS = {
allFunctions: 'كل الدوال',
syntax: 'الصيغة',
},
mobileFunction: {
title: 'إدراج دالة',
recommended: 'موصى بها',
recent: 'الأخيرة',
details: 'التفاصيل',
insert: 'إدراج الدالة',
empty: 'لا توجد دوال',
close: 'إغلاق',
},
operation: {
copyFormulaOnly: 'نسخ الصيغة فقط',
pasteFormula: 'لصق الصيغة',
@@ -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',
@@ -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',
@@ -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',
@@ -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',
@@ -75,6 +75,15 @@ const locale: typeof enUS = {
allFunctions: 'همه توابع',
syntax: 'سینتکس',
},
mobileFunction: {
title: 'درج تابع',
recommended: 'پیشنهادی',
recent: 'اخیر',
details: 'جزئیات',
insert: 'درج تابع',
empty: 'تابعی وجود ندارد',
close: 'بستن',
},
operation: {
copyFormulaOnly: 'کپی فقط فرمول',
pasteFormula: 'چسباندن فرمول',
@@ -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',
@@ -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',
@@ -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',
@@ -75,6 +75,15 @@ const locale: typeof enUS = {
allFunctions: 'すべての関数',
syntax: '構文',
},
mobileFunction: {
title: '関数を挿入',
recommended: 'おすすめ',
recent: '最近使用した関数',
details: '詳細',
insert: '関数を挿入',
empty: '関数がありません',
close: '閉じる',
},
operation: {
copyFormulaOnly: '数式のみをコピー',
pasteFormula: '数式を貼り付け',
@@ -75,6 +75,15 @@ const locale: typeof enUS = {
allFunctions: '모든 함수',
syntax: '구문',
},
mobileFunction: {
title: '함수 삽입',
recommended: '추천',
recent: '최근',
details: '세부 정보',
insert: '함수 삽입',
empty: '함수가 없습니다',
close: '닫기',
},
operation: {
copyFormulaOnly: '수식만 복사',
pasteFormula: '수식 붙여넣기',
@@ -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łę',
@@ -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',
@@ -75,6 +75,15 @@ const locale: typeof enUS = {
allFunctions: 'Все функции',
syntax: 'СИНТАКСИС',
},
mobileFunction: {
title: 'Вставить функцию',
recommended: 'Рекомендуемые',
recent: 'Недавние',
details: 'Подробнее',
insert: 'Вставить функцию',
empty: 'Нет функций',
close: 'Закрыть',
},
operation: {
copyFormulaOnly: 'Копировать только формулу',
pasteFormula: 'Вставить Формулу',
@@ -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',
@@ -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',
@@ -78,6 +78,15 @@ const locale: typeof enUS = {
syntax: '语法',
},
mobileFunction: {
title: '插入函数',
recommended: '推荐',
recent: '最近',
details: '详情',
insert: '插入函数',
empty: '暂无函数',
close: '关闭',
},
operation: {
copyFormulaOnly: '仅复制公式',
pasteFormula: '仅粘贴公式',
@@ -78,6 +78,15 @@ const locale: typeof enUS = {
syntax: '語法',
},
mobileFunction: {
title: '插入函數',
recommended: '推薦',
recent: '最近',
details: '詳細資料',
insert: '插入函數',
empty: '暫無函數',
close: '關閉',
},
operation: {
copyFormulaOnly: '僅複製公式',
pasteFormula: '僅貼上公式',
@@ -78,6 +78,15 @@ const locale: typeof enUS = {
syntax: '語法',
},
mobileFunction: {
title: '插入函數',
recommended: '建議',
recent: '最近',
details: '詳細資料',
insert: '插入函數',
empty: '沒有函數',
close: '關閉',
},
operation: {
copyFormulaOnly: '僅複製公式',
pasteFormula: '僅貼上公式',
@@ -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)];
@@ -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(() => {
@@ -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 = (
@@ -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(() => {
@@ -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;
}
@@ -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<ITextRange, 'startOffset' | 'endOffset'> | 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<ITextRange, 'startOffset' | 'endOffset'> | 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<IFormulaEditorRef>) => {
const {
errorText,
@@ -175,6 +215,11 @@ export const FormulaEditor = forwardRef((props: IFormulaEditorProps, ref: Ref<IF
style,
borderless = false,
canvasStyle,
mobile = false,
mobileFxRequest,
mobileFunctionPanelRequest,
mobileOperatorRequest,
onMobileFormulaActiveChange: propOnMobileFormulaActiveChange,
} = props;
const editorService = useDependency(IEditorService);
@@ -191,6 +236,7 @@ export const FormulaEditor = forwardRef((props: IFormulaEditorProps, ref: Ref<IF
},
}));
const onFormulaSelectingChange = useEvent(propOnFormulaSelectingChange);
const onMobileFormulaActiveChange = useEvent(propOnMobileFormulaActiveChange);
const searchFunctionRef = useRef<HTMLElement>(null);
const editorRef = useRef<Editor>(undefined);
const [editor, setEditor] = useState<Editor>();
@@ -212,7 +258,16 @@ export const FormulaEditor = forwardRef((props: IFormulaEditorProps, ref: Ref<IF
const formulaTextRef = useStateRef(formulaText);
const formulaWithoutEqualSymbol = useMemo(() => 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
onChange(formulaText);
}, [formulaText, onChange]);
useEffect(() => {
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<IF
return () => 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<IF
}, [_isFocus, docSelectionRenderService, editor, focus, resetSelection, resetSelectionOnBlur]);
const { checkScrollBar } = useResize(editor, isSingle, autoScrollbar);
useRefactorEffect(isFocus, isSelecting, unitId, editorId, disableContextMenu);
useRefactorEffect(isFocus, isSelecting, unitId, editorId, disableContextMenu, mobile);
useLeftAndRightArrow(Boolean(isFocus && isFocusing && moveCursor), selectingMode, editor, onMoveInEditor, getRefSelectionCount);
const handleSelectionChange = useEvent((refString: string, offset: number, isEnd: boolean) => {
@@ -491,7 +567,8 @@ export const FormulaEditor = forwardRef((props: IFormulaEditorProps, ref: Ref<IF
isSupportAcrossSheet,
Boolean(selectingMode),
editor,
handleSelectionChange
handleSelectionChange,
mobile
);
useSwitchSheet(isFocus && Boolean(isSelecting && docFocusing), unitId, isSupportAcrossSheet, setIsFocus, onBlur, () => {
highlight(formulaTextRef.current, false, true);
@@ -514,6 +591,67 @@ export const FormulaEditor = forwardRef((props: IFormulaEditorProps, ref: Ref<IF
}
};
const closeMobileFunctionPanel = () => {
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<HTMLDivElement>) => {
if (hasActiveFormulaEmbedInteraction(formulaEditorContainerRef.current)) {
return;
@@ -555,7 +693,7 @@ export const FormulaEditor = forwardRef((props: IFormulaEditorProps, ref: Ref<IF
{errorText}
</div>
)}
{(functionScreenTips && editor && formulaWithoutEqualSymbol !== '') && (
{(!mobile && functionScreenTips && editor && formulaWithoutEqualSymbol !== '') && (
<HelpFunction
editor={editor}
isFocus={isFocus}
@@ -570,8 +708,14 @@ export const FormulaEditor = forwardRef((props: IFormulaEditorProps, ref: Ref<IF
onSelect={handleFunctionSelect}
ref={searchFunctionRef}
editor={editor}
mobile={mobile}
/>
)}
<MobileFunctionPanel
open={mobile && mobileFunctionPanelOpen}
onClose={closeMobileFunctionPanel}
onInsert={handleMobileFunctionInsert}
/>
</div>
);
});
@@ -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<LocaleKey>('sheets-formula-ui.mobileFunction.title'),
search: localeService.t<LocaleKey>('sheets-formula-ui.moreFunctions.searchFunctionPlaceholder'),
recommended: localeService.t<LocaleKey>('sheets-formula-ui.mobileFunction.recommended'),
recent: localeService.t<LocaleKey>('sheets-formula-ui.mobileFunction.recent'),
all: localeService.t<LocaleKey>('sheets-formula-ui.moreFunctions.allFunctions'),
details: localeService.t<LocaleKey>('sheets-formula-ui.mobileFunction.details'),
back: localeService.t<LocaleKey>('sheets-formula-ui.moreFunctions.prev'),
insert: localeService.t<LocaleKey>('sheets-formula-ui.mobileFunction.insert'),
empty: localeService.t<LocaleKey>('sheets-formula-ui.mobileFunction.empty'),
syntax: localeService.t<LocaleKey>('sheets-formula-ui.moreFunctions.syntax'),
close: localeService.t<LocaleKey>('sheets-formula-ui.mobileFunction.close'),
};
const [query, setQuery] = useState('');
const [category, setCategory] = useState<MobileFunctionCategory>('recommended');
const [details, setDetails] = useState<IFunctionInfo | null>(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<ISearchItem[]>(() => {
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 (
<div
data-u-comp="mobile-formula-function-panel"
className="univer-fixed univer-inset-0 univer-z-[1200] univer-bg-black/20"
onPointerDown={(event) => event.stopPropagation()}
>
<section
role="dialog"
aria-label={copy.title}
className="
univer-absolute univer-inset-x-0 univer-bottom-0 univer-flex univer-h-[80dvh] univer-flex-col
univer-overflow-hidden univer-rounded-t-2xl univer-bg-gray-0 univer-shadow-lg
dark:!univer-bg-gray-900
"
>
<div className="univer-flex univer-justify-center univer-pb-2 univer-pt-3">
<div
className="
univer-h-1 univer-w-10 univer-rounded-full univer-bg-gray-300
dark:!univer-bg-gray-600
"
/>
</div>
<header className="univer-flex univer-h-12 univer-items-center univer-gap-2 univer-px-4">
{details && (
<button
type="button"
className="
univer-h-10 univer-appearance-none univer-rounded-lg univer-border-0 univer-bg-transparent
univer-px-3 univer-text-sm univer-text-primary-600 univer-outline-none
active:univer-bg-primary-50
"
onClick={() => setDetails(null)}
>
{copy.back}
</button>
)}
<h2
className="
univer-m-0 univer-flex-1 univer-text-base univer-font-semibold univer-text-gray-900
dark:!univer-text-gray-0
"
>
{details?.functionName ?? copy.title}
</h2>
<button
type="button"
aria-label={copy.close}
className="
univer-size-10 univer-appearance-none univer-rounded-lg univer-border-0 univer-bg-transparent
univer-text-xl univer-text-gray-600 univer-outline-none
active:univer-bg-gray-100
dark:!univer-text-gray-200
dark:active:!univer-bg-gray-700
"
onClick={onClose}
>
×
</button>
</header>
{details
? (
<div className={clsx('univer-flex-1 univer-overflow-y-auto univer-px-4 univer-pb-4', scrollbarClassName)}>
<div
className="
univer-rounded-xl univer-bg-gray-50 univer-p-4
dark:!univer-bg-gray-800
"
>
<p
className="
univer-m-0 univer-text-sm univer-leading-6 univer-text-gray-700
dark:!univer-text-gray-200
"
>
{details.description}
</p>
<div
className="
univer-mt-4 univer-text-xs univer-text-gray-500
dark:!univer-text-gray-300
"
>
{copy.syntax}
</div>
<code
className="
univer-mt-2 univer-block univer-break-words univer-rounded-lg univer-bg-gray-0
univer-p-3 univer-text-sm
dark:!univer-bg-gray-900
"
>
{`${details.functionName}(${details.functionParameter.map((item) => item.name).join(', ')})`}
</code>
</div>
<button
type="button"
data-u-comp="mobile-formula-insert"
className="
univer-mt-4 univer-h-12 univer-w-full univer-appearance-none univer-rounded-xl
univer-border-0 univer-bg-primary-600 univer-text-sm univer-font-medium
univer-text-white univer-outline-none
active:univer-bg-primary-700
"
onClick={() => handleInsert(details.functionName)}
>
{copy.insert}
</button>
</div>
)
: (
<>
<div className="univer-px-4 univer-pb-3">
<input
value={query}
aria-label={copy.search}
placeholder={copy.search}
className="
univer-box-border univer-h-11 univer-w-full univer-rounded-xl univer-border
univer-border-solid univer-border-gray-200 univer-bg-gray-50 univer-px-4
univer-text-base univer-outline-none
focus:univer-border-primary-500
dark:!univer-border-gray-700 dark:!univer-bg-gray-800 dark:!univer-text-gray-0
"
onChange={(event) => setQuery(event.target.value)}
/>
</div>
{!query && (
<div
className="
univer-flex univer-shrink-0 univer-gap-2 univer-overflow-x-auto univer-px-4
univer-pb-3
"
style={{ scrollbarWidth: 'none' }}
>
{categories.map((item) => (
<button
key={String(item.value)}
type="button"
className={clsx(`
univer-h-10 univer-shrink-0 univer-appearance-none univer-rounded-xl
univer-border-0 univer-px-4 univer-text-sm univer-outline-none
active:univer-scale-[0.98]
`, {
'univer-bg-primary-100 univer-text-primary-700 dark:!univer-bg-primary-900 dark:!univer-text-primary-200': category === item.value,
'univer-bg-gray-100 univer-text-gray-700 dark:!univer-bg-gray-800 dark:!univer-text-gray-200': category !== item.value,
})}
onClick={() => setCategory(item.value)}
>
{item.label}
</button>
))}
</div>
)}
<div className={clsx('univer-flex-1 univer-overflow-y-auto univer-px-4 univer-pb-4', scrollbarClassName)}>
{functions.length === 0 && (
<div
className="univer-py-12 univer-text-center univer-text-sm univer-text-gray-400"
>
{copy.empty}
</div>
)}
{functions.map((item) => (
<div
key={item.name}
className="
univer-mb-2 univer-flex univer-min-h-14 univer-items-center univer-rounded-xl
univer-bg-gray-50 univer-pl-4
dark:!univer-bg-gray-800
"
>
<button
type="button"
className="
univer-min-w-0 univer-flex-1 univer-appearance-none univer-border-0
univer-bg-transparent univer-py-3 univer-text-left univer-outline-none
active:univer-opacity-60
"
onClick={() => handleInsert(item.name)}
>
<span
className="
univer-block univer-text-sm univer-font-medium univer-text-gray-900
dark:!univer-text-gray-0
"
>
{item.name}
</span>
<span
className="
univer-mt-1 univer-block univer-truncate univer-text-xs
univer-text-gray-500
dark:!univer-text-gray-300
"
>
{item.desc}
</span>
</button>
<button
type="button"
className="
univer-h-12 univer-shrink-0 univer-appearance-none univer-border-0
univer-bg-transparent univer-px-4 univer-text-sm univer-text-primary-600
univer-outline-none
active:univer-bg-primary-50
"
onClick={() => setDetails(descriptionService.getFunctionInfo(item.name) ?? null)}
>
{copy.details}
</button>
</div>
))}
</div>
</>
)}
</section>
</div>
);
}
@@ -35,10 +35,11 @@ interface ISearchFunctionProps {
onChange?: (functionName: string) => void;
editor: Editor;
onClose?: () => void;
mobile?: boolean;
};
export const SearchFunction = forwardRef<HTMLElement, ISearchFunctionProps>(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) => (
<li
@@ -210,7 +211,7 @@ function SearchFunctionFactory(props: ISearchFunctionProps, ref: any) {
univer-box-border univer-cursor-pointer univer-rounded univer-px-2 univer-py-1
univer-text-gray-900 univer-transition-colors
dark:!univer-text-gray-0
`, {
`, { 'univer-flex univer-min-h-12 univer-flex-col univer-justify-center univer-px-3 univer-py-2': mobile }, {
'univer-bg-gray-200 dark:!univer-bg-gray-600': active === index,
})}
onMouseEnter={() => handleLiMouseEnter(index)}
@@ -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 (
<div className="univer-pointer-events-none univer-visible univer-fixed univer-inset-0 univer-z-[1300]">
<MobileDrawer
componentName="mobile-range-selector-drawer"
snap={snap}
expandLabel={title}
collapseLabel={title}
onSnapChange={onSnapChange}
onClose={onClose}
role="dialog"
ariaLabel={title}
panelClassName="
univer-pointer-events-auto univer-bg-gray-0 univer-text-gray-900
dark:!univer-bg-gray-900 dark:!univer-text-gray-0
"
contentClassName="univer-min-h-0 univer-px-4"
header={(
<header
className="
univer-flex univer-h-12 univer-flex-1 univer-items-center univer-px-4 univer-text-base
univer-font-semibold
"
>
{title}
</header>
)}
footer={(
<footer className="univer-box-border univer-shrink-0 univer-p-4">
<ActionRow>
<Button onClick={onClose}>{cancelText}</Button>
<Button variant="primary" onClick={onConfirm}>{confirmText}</Button>
</ActionRow>
</footer>
)}
>
{children}
</MobileDrawer>
</div>
);
}
@@ -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<void>();
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({
@@ -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(
<RediContext.Provider value={{ injector }}>
<RangeSelectorDialog
visible
mobile
initialValue={[{
unitId: 'book-1',
sheetName: 'Sheet1',
range: {
startRow: 0,
endRow: 1,
startColumn: 0,
endColumn: 1,
rangeType: RANGE_TYPE.NORMAL,
},
}]}
unitId="book-1"
subUnitId="sheet-1"
maxRangeCount={1}
onConfirm={(ranges) => RangeDialogState.confirmed.push(ranges)}
onClose={() => {
RangeDialogState.closed += 1;
}}
/>
</RediContext.Provider>
);
});
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,
}));
});
});
@@ -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<string[]>([]);
const [focusIndex, setFocusIndex] = useState(0);
const [drawerSnap, setDrawerSnap] = useState<MobileDrawerSnap>('compact');
const scrollbarRef = useRef<HTMLDivElement>(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) => (
<div
ref={scrollbarRef}
className={clsx(
'univer-overflow-y-auto',
scrollbarClassName,
mobileLayout ? 'univer-max-h-[22dvh]' : '-univer-mx-6 univer-max-h-60 univer-px-6'
)}
>
{ranges.map((text, index) => (
<div
key={index}
className={clsx('univer-mb-2 univer-flex univer-items-center', mobileLayout
? 'univer-gap-2'
: 'univer-gap-4')}
>
<Input
className={clsx('univer-box-border univer-h-10 univer-w-full', {
'univer-border-primary-600': focusIndex === index,
})}
placeholder={localeService.t<LocaleKey>('sheets-formula-ui.rangeSelector.placeHolder')}
onFocus={() => setFocusIndex(index)}
value={text}
onChange={(value) => handleRangeInput(index, value)}
/>
{ranges.length > 1 && (
<button
type="button"
className="
univer-flex univer-size-10 univer-shrink-0 univer-items-center univer-justify-center
univer-rounded-lg univer-border-0 univer-bg-transparent univer-text-gray-600
active:univer-bg-gray-100
dark:!univer-text-gray-300
dark:active:!univer-bg-gray-700
"
onClick={() => handleRangeRemove(index)}
>
<DeleteIcon className="univer-cursor-pointer" />
</button>
)}
</div>
))}
{ranges.length < maxRangeCount && (
<Button className={mobileLayout ? 'univer-h-10 univer-w-full' : undefined} variant="link" onClick={handleRangeAdd}>
<IncreaseIcon />
<span>{localeService.t<LocaleKey>('sheets-formula-ui.rangeSelector.addAnotherRange')}</span>
</Button>
)}
</div>
);
if (mobile) {
return (
<MobileRangeSelectorDialog
visible={visible}
snap={drawerSnap}
title={localeService.t<LocaleKey>('sheets-formula-ui.rangeSelector.title')}
cancelText={localeService.t<LocaleKey>('sheets-formula-ui.rangeSelector.cancel')}
confirmText={localeService.t<LocaleKey>('sheets-formula-ui.rangeSelector.confirm')}
onSnapChange={setDrawerSnap}
onClose={onClose}
onConfirm={confirmRanges}
>
{rangeInputs(true)}
</MobileRangeSelectorDialog>
);
}
return (
<Dialog
width="328px"
@@ -178,16 +263,7 @@ export function RangeSelectorDialog(props: IRangeSelectorDialogProps) {
<Button onClick={onClose}>{localeService.t<LocaleKey>('sheets-formula-ui.rangeSelector.cancel')}</Button>
<Button
variant="primary"
onClick={() => {
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) }))
);
}}
onClick={confirmRanges}
>
{localeService.t<LocaleKey>('sheets-formula-ui.rangeSelector.confirm')}
</Button>
@@ -195,38 +271,7 @@ export function RangeSelectorDialog(props: IRangeSelectorDialogProps) {
)}
onClose={onClose}
>
<div
ref={scrollbarRef}
className={clsx('-univer-mx-6 univer-max-h-60 univer-overflow-y-auto univer-px-6', scrollbarClassName)}
>
{ranges.map((text, index) => (
<div key={index} className="univer-mb-2 univer-flex univer-items-center univer-gap-4">
<Input
className={clsx('univer-w-full', {
'univer-border-primary-600': focusIndex === index,
})}
placeholder={localeService.t<LocaleKey>('sheets-formula-ui.rangeSelector.placeHolder')}
onFocus={() => setFocusIndex(index)}
value={text}
onChange={(value) => handleRangeInput(index, value)}
/>
{ranges.length > 1 && (
<DeleteIcon
className="univer-cursor-pointer"
onClick={() => handleRangeRemove(index)}
/>
)}
</div>
))}
{ranges.length < maxRangeCount && (
<div>
<Button variant="link" onClick={handleRangeAdd}>
<IncreaseIcon />
<span>{localeService.t<LocaleKey>('sheets-formula-ui.rangeSelector.addAnotherRange')}</span>
</Button>
</div>
)}
</div>
{rangeInputs()}
</Dialog>
);
}
@@ -243,6 +288,7 @@ export function stringifyRanges(ranges: IUnitRangeName[]) {
export function RangeSelector(props: IRangeSelectorProps) {
const [editor, setEditor] = useState<Editor | null>(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);
@@ -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 {
@@ -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<IHyperLinkPopup, 'disposable' | 'editPermission'>) => {
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<INeedCheckDisposable>;
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<Workbook>(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<Workbook>(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<Workbook>(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);
}
}
@@ -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 (
<div
className={clsx(`
univer-box-border univer-w-[296px] univer-rounded-xl univer-bg-gray-0 univer-p-4 univer-shadow-md
dark:!univer-bg-gray-900
`, borderClassName)}
className={clsx(
`
univer-box-border univer-bg-gray-0
dark:!univer-bg-gray-900
`,
mobile
? 'univer-w-full univer-p-0'
: clsx('univer-w-[296px] univer-rounded-xl univer-p-4 univer-shadow-md', borderClassName)
)}
>
{showLabel
? (
@@ -566,7 +572,12 @@ export const CellLinkEdit = () => {
setPayload={setPayload}
/>
)}
<div className="univer-flex univer-flex-row univer-justify-end univer-gap-2">
<ActionRow
className={clsx(
'univer-flex univer-flex-row univer-justify-end univer-gap-2',
mobile && 'univer-mt-5 univer-w-full'
)}
>
<Button
onClick={() => {
if (editing) {
@@ -585,7 +596,7 @@ export const CellLinkEdit = () => {
>
{localeService.t<LocaleKey>('sheets-hyper-link-ui.form.ok')}
</Button>
</div>
</ActionRow>
</div>
);
};
@@ -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]: <LinkIcon />,
@@ -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 (
<MobileCellLinkPopup
name={linkObj.name}
invalid={isError}
copyPermission={Boolean(copyPermission)}
editPermission={Boolean(editPermission)}
copyText={localeService.t<LocaleKey>('sheets-hyper-link-ui.popup.copy')}
editText={localeService.t<LocaleKey>('sheets-hyper-link-ui.popup.edit')}
removeText={localeService.t<LocaleKey>('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<LocaleKey>('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 (
<div
className={clsx(`
@@ -0,0 +1,60 @@
/**
* 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 { MobileActionRow } from '@univerjs/design';
interface IMobileCellLinkPopupProps {
name: string;
copyText: string;
editText: string;
removeText: string;
invalid: boolean;
copyPermission: boolean;
editPermission: boolean;
onNavigate: () => void;
onCopy: () => void;
onEdit: () => void;
onRemove: () => void;
}
export function MobileCellLinkPopup(props: IMobileCellLinkPopupProps) {
return (
<div className="univer-flex univer-flex-col univer-gap-2">
<MobileActionRow
title={<span className="univer-truncate">{props.name}</span>}
aria-label={props.name}
variant="subtle"
disabled={props.invalid}
onClick={props.onNavigate}
/>
{props.copyPermission && (
<MobileActionRow
title={props.copyText}
aria-label={props.copyText}
variant="subtle"
disabled={props.invalid}
onClick={props.onCopy}
/>
)}
{props.editPermission && (
<>
<MobileActionRow title={props.editText} aria-label={props.editText} variant="subtle" onClick={props.onEdit} />
<MobileActionRow title={props.removeText} aria-label={props.removeText} variant="subtle" onClick={props.onRemove} />
</>
)}
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More