mirror of
https://github.com/dream-num/univer.git
synced 2026-09-01 15:29:43 +08:00
feat(sheets-ui): improve mobile editing interactions (#7605)
This commit is contained in:
@@ -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}>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -21,6 +21,7 @@ const locale: typeof enUS = {
|
||||
Accessibility: {
|
||||
closeBadge: 'إغلاق الشارة',
|
||||
close: 'إغلاق',
|
||||
menu: 'القائمة',
|
||||
previous: 'السابق',
|
||||
next: 'التالي',
|
||||
imageGallery: 'معرض الصور',
|
||||
|
||||
@@ -21,6 +21,7 @@ const locale: typeof enUS = {
|
||||
Accessibility: {
|
||||
closeBadge: 'Tanca la insígnia',
|
||||
close: 'Tanca',
|
||||
menu: 'Menú',
|
||||
previous: 'Anterior',
|
||||
next: 'Següent',
|
||||
imageGallery: 'Galeria d’imatges',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -19,6 +19,7 @@ const locale = {
|
||||
Accessibility: {
|
||||
closeBadge: 'Close badge',
|
||||
close: 'Close',
|
||||
menu: 'Menu',
|
||||
previous: 'Previous',
|
||||
next: 'Next',
|
||||
imageGallery: 'Image gallery',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -21,6 +21,7 @@ const locale: typeof enUS = {
|
||||
Accessibility: {
|
||||
closeBadge: 'بستن نشان',
|
||||
close: 'بستن',
|
||||
menu: 'منو',
|
||||
previous: 'قبلی',
|
||||
next: 'بعدی',
|
||||
imageGallery: 'گالری تصاویر',
|
||||
|
||||
@@ -21,6 +21,7 @@ const locale: typeof enUS = {
|
||||
Accessibility: {
|
||||
closeBadge: 'Fermer le badge',
|
||||
close: 'Fermer',
|
||||
menu: 'Menu',
|
||||
previous: 'Précédent',
|
||||
next: 'Suivant',
|
||||
imageGallery: 'Galerie d’images',
|
||||
|
||||
@@ -21,6 +21,7 @@ const locale: typeof enUS = {
|
||||
Accessibility: {
|
||||
closeBadge: 'Tutup lencana',
|
||||
close: 'Tutup',
|
||||
menu: 'Menu',
|
||||
previous: 'Sebelumnya',
|
||||
next: 'Berikutnya',
|
||||
imageGallery: 'Galeri gambar',
|
||||
|
||||
@@ -21,6 +21,7 @@ const locale: typeof enUS = {
|
||||
Accessibility: {
|
||||
closeBadge: 'Chiudi badge',
|
||||
close: 'Chiudi',
|
||||
menu: 'Menu',
|
||||
previous: 'Precedente',
|
||||
next: 'Successivo',
|
||||
imageGallery: 'Galleria immagini',
|
||||
|
||||
@@ -21,6 +21,7 @@ const locale: typeof enUS = {
|
||||
Accessibility: {
|
||||
closeBadge: 'バッジを閉じる',
|
||||
close: '閉じる',
|
||||
menu: 'メニュー',
|
||||
previous: '前へ',
|
||||
next: '次へ',
|
||||
imageGallery: '画像ギャラリー',
|
||||
|
||||
@@ -21,6 +21,7 @@ const locale: typeof enUS = {
|
||||
Accessibility: {
|
||||
closeBadge: '배지 닫기',
|
||||
close: '닫기',
|
||||
menu: '메뉴',
|
||||
previous: '이전',
|
||||
next: '다음',
|
||||
imageGallery: '이미지 갤러리',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -21,6 +21,7 @@ const locale: typeof enUS = {
|
||||
Accessibility: {
|
||||
closeBadge: 'Закрыть метку',
|
||||
close: 'Закрыть',
|
||||
menu: 'Меню',
|
||||
previous: 'Предыдущее',
|
||||
next: 'Следующее',
|
||||
imageGallery: 'Галерея изображений',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -21,6 +21,7 @@ const locale: typeof enUS = {
|
||||
Accessibility: {
|
||||
closeBadge: '关闭徽章',
|
||||
close: '关闭',
|
||||
menu: '菜单',
|
||||
previous: '上一个',
|
||||
next: '下一个',
|
||||
imageGallery: '图片库',
|
||||
|
||||
@@ -21,6 +21,7 @@ const locale: typeof enUS = {
|
||||
Accessibility: {
|
||||
closeBadge: '關閉徽章',
|
||||
close: '關閉',
|
||||
menu: '選單',
|
||||
previous: '上一個',
|
||||
next: '下一個',
|
||||
imageGallery: '圖片庫',
|
||||
|
||||
@@ -21,6 +21,7 @@ const locale: typeof enUS = {
|
||||
Accessibility: {
|
||||
closeBadge: '關閉徽章',
|
||||
close: '關閉',
|
||||
menu: '選單',
|
||||
previous: '上一個',
|
||||
next: '下一個',
|
||||
imageGallery: '圖片庫',
|
||||
|
||||
@@ -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() {
|
||||
|
||||
+17
-1
@@ -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);
|
||||
|
||||
+17
-2
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+17
-1
@@ -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 }],
|
||||
|
||||
+12
-8
@@ -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: '僅貼上公式',
|
||||
|
||||
+45
@@ -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(() => {
|
||||
|
||||
+5
-1
@@ -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(() => {
|
||||
|
||||
+16
-3
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
+344
@@ -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>
|
||||
);
|
||||
}
|
||||
+7
-6
@@ -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>
|
||||
);
|
||||
}
|
||||
+17
-2
@@ -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({
|
||||
|
||||
+61
@@ -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
Reference in New Issue
Block a user