feat(sheets): support shrink to fit (#7271)

This commit is contained in:
白熱
2026-07-15 21:29:33 +08:00
committed by GitHub
parent 00d899a45f
commit 3b67dccfa1
20 changed files with 385 additions and 32 deletions
@@ -276,6 +276,8 @@ export interface IStyleBase {
* Properties of cell style
*/
export interface IStyleData extends IStyleBase {
/** Whether the font size should shrink to fit the cell width. */
stf?: BooleanNumber;
/**
* textRotation
*/
@@ -320,6 +322,7 @@ export const STYLE_KEYS = defineExactKeys<IStyleData>()([
'cl',
'va',
'n',
'stf',
'tr',
'td',
'ht',
@@ -16,6 +16,8 @@
import { cleanup, fireEvent, 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 { Badge } from '../Badge';
import '@testing-library/jest-dom/vitest';
@@ -29,8 +31,12 @@ describe('Badge', () => {
it('renders closable badge', () => {
const onClose = vi.fn();
render(<Badge closable onClose={onClose}>Closable Badge</Badge>);
const closeButton = screen.getByLabelText('Close badge');
render(
<ConfigProvider locale={enUS.design} mountContainer={document.body}>
<Badge closable onClose={onClose}>Closable Badge</Badge>
</ConfigProvider>
);
const closeButton = screen.getByLabelText(enUS.design.Accessibility.closeBadge);
expect(closeButton).toBeInTheDocument();
fireEvent.click(closeButton);
expect(onClose).toHaveBeenCalled();
@@ -18,6 +18,7 @@ import type { ComponentProps } from 'react';
import { cleanup, fireEvent, render } from '@testing-library/react';
import { useState } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import enUS from '../../../locale/en-US';
import { ConfigProvider } from '../../config-provider/ConfigProvider';
import { ColorInput } from '../ColorInput';
import { ColorPicker } from '../ColorPicker';
@@ -76,10 +77,14 @@ describe('ColorPicker', () => {
});
it('should open dialog when more is clicked', () => {
const { container } = render(<ColorPicker />);
const { container } = render(
<ConfigProvider locale={enUS.design} mountContainer={document.body}>
<ColorPicker />
</ConfigProvider>
);
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 }));
fireEvent.click(moreLink);
expect(document.body.innerHTML).toContain('univer-grid univer-w-64 univer-gap-2');
}
});
@@ -16,6 +16,7 @@
import { cleanup, fireEvent, render } 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 { Dialog } from '../Dialog';
import '@testing-library/jest-dom/vitest';
@@ -54,9 +55,13 @@ describe('Dialog', () => {
it('should call onOk and onCancel', () => {
const onOk = vi.fn();
const onCancel = vi.fn();
const { getByText } = render(<Dialog open showOk showCancel onOk={onOk} onCancel={onCancel}>content</Dialog>);
getByText(/ok|确定/i).click();
getByText(/cancel|取消/i).click();
const { getByText } = render(
<ConfigProvider locale={enUS.design} mountContainer={document.body}>
<Dialog open showOk showCancel onOk={onOk} onCancel={onCancel}>content</Dialog>
</ConfigProvider>
);
getByText(enUS.design.Confirm.confirm).click();
getByText(enUS.design.Confirm.cancel).click();
expect(onOk).toHaveBeenCalled();
expect(onCancel).toHaveBeenCalled();
});
@@ -14,8 +14,11 @@
* limitations under the License.
*/
import type { ComponentProps, PropsWithChildren } from 'react';
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import enUS from '../../../locale/en-US';
import { ConfigProvider } from '../../config-provider/ConfigProvider';
import { Gallery } from '../Gallery';
import '@testing-library/jest-dom/vitest';
@@ -25,6 +28,14 @@ const images = [
'https://example.com/3.jpg',
];
function LocaleProvider({ children }: PropsWithChildren) {
return <ConfigProvider locale={enUS.design} mountContainer={document.body}>{children}</ConfigProvider>;
}
function renderGallery(props: ComponentProps<typeof Gallery>) {
return render(<Gallery {...props} />, { wrapper: LocaleProvider });
}
afterEach(() => {
cleanup();
});
@@ -34,12 +45,12 @@ describe('Gallery', () => {
vi.useRealTimers();
});
it('does not render when open is false', () => {
render(<Gallery images={images} open={false} />);
renderGallery({ images, open: false });
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
it('renders and displays the first image when open', () => {
render(<Gallery images={images} open={true} />);
renderGallery({ images, open: true });
const dialog = screen.getByRole('dialog');
expect(dialog).toBeInTheDocument();
const img = screen.getByRole('img');
@@ -49,7 +60,7 @@ describe('Gallery', () => {
it('calls onOpenChange(false) when clicking the overlay', () => {
const onOpenChange = vi.fn();
render(<Gallery images={images} open={true} onOpenChange={onOpenChange} />);
renderGallery({ images, open: true, onOpenChange });
// The overlay is the first child of the dialog
const dialog = screen.getByRole('dialog');
const overlay = dialog.querySelector('div');
@@ -61,20 +72,20 @@ describe('Gallery', () => {
it('calls onOpenChange(false) when pressing ESC', () => {
const onOpenChange = vi.fn();
render(<Gallery images={images} open={true} onOpenChange={onOpenChange} />);
renderGallery({ images, open: true, onOpenChange });
fireEvent.keyDown(window, { key: 'Escape', code: 'Escape' });
expect(onOpenChange).toHaveBeenCalledWith(false);
});
it('toolbar buttons have correct aria-labels', () => {
render(<Gallery images={images} open={true} />);
renderGallery({ images, open: true });
expect(screen.getByRole('button', { name: /zoom in/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /zoom out/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /reset zoom/i })).toBeInTheDocument();
});
it('zoom in/out/reset buttons adjust the image scale', () => {
render(<Gallery images={images} open={true} />);
renderGallery({ images, open: true });
const img = screen.getByRole('img');
const zoomInBtn = screen.getByRole('button', { name: /zoom in/i });
const zoomOutBtn = screen.getByRole('button', { name: /zoom out/i });
@@ -93,7 +104,7 @@ describe('Gallery', () => {
});
it('can switch images using the pager', () => {
render(<Gallery images={images} open={true} />);
renderGallery({ images, open: true });
const nextButton = document.querySelector('[data-u-comp="pager-right-arrow"]') as HTMLButtonElement;
fireEvent.click(nextButton);
const img = screen.getByRole('img');
@@ -102,14 +113,14 @@ describe('Gallery', () => {
});
it('does not render pagination for a single image', () => {
render(<Gallery images={[images[0]]} open={true} />);
renderGallery({ images: [images[0]], open: true });
expect(document.querySelector('[data-u-comp="pager"]')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: /zoom in/i })).toBeInTheDocument();
});
it('should zoom with wheel event and keep value in range', () => {
render(<Gallery images={images} open={true} />);
renderGallery({ images, open: true });
const img = screen.getByRole('img');
const getScale = () => Number.parseFloat((img.style.transform.match(/scale\(([^)]+)\)/)?.[1] ?? '1'));
@@ -137,7 +148,7 @@ describe('Gallery', () => {
it('should schedule close animation timer when closing', () => {
const timeoutSpy = vi.spyOn(globalThis, 'setTimeout');
const { rerender } = render(<Gallery images={images} open={true} />);
const { rerender } = renderGallery({ images, open: true });
expect(screen.getByRole('dialog')).toBeInTheDocument();
rerender(<Gallery images={images} open={false} />);
@@ -18,6 +18,8 @@ import type { ComponentProps } from 'react';
import { cleanup, fireEvent, render } from '@testing-library/react';
import { useState } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import enUS from '../../../locale/en-US';
import { ConfigProvider } from '../../config-provider/ConfigProvider';
import { InputNumber } from '../InputNumber';
import '@testing-library/jest-dom/vitest';
@@ -305,13 +307,15 @@ describe('InputNumber', () => {
it('should support rtl layout classes and keep increment/decrement behavior', () => {
const onLocalChange = vi.fn();
const { container } = render(
<div dir="rtl">
<InputNumber defaultValue={1} onChange={onLocalChange} />
</div>
<ConfigProvider locale={enUS.design} mountContainer={document.body}>
<div dir="rtl">
<InputNumber defaultValue={1} onChange={onLocalChange} />
</div>
</ConfigProvider>
);
const incrementButton = container.querySelector('[aria-label="increment"]') as HTMLElement;
const decrementButton = container.querySelector('[aria-label="decrement"]') as HTMLElement;
const incrementButton = container.querySelector(`[aria-label="${enUS.design.Accessibility.increment}"]`) as HTMLElement;
const decrementButton = container.querySelector(`[aria-label="${enUS.design.Accessibility.decrement}"]`) as HTMLElement;
const controlsWrapper = incrementButton.parentElement as HTMLElement;
expect(controlsWrapper.className).toContain('rtl:univer-left-px');
@@ -16,6 +16,8 @@
import { cleanup, fireEvent, 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 { MultipleSelect } from '../MultipleSelect';
import { Select } from '../Select';
import '@testing-library/jest-dom/vitest';
@@ -114,9 +116,13 @@ describe('MultipleSelect', () => {
it('should remove badge value when close icon is clicked', () => {
const handleChange = vi.fn();
render(<MultipleSelect value={['1', '2']} options={options} onChange={handleChange} />);
render(
<ConfigProvider locale={enUS.design} mountContainer={document.body}>
<MultipleSelect value={['1', '2']} options={options} onChange={handleChange} />
</ConfigProvider>
);
const closeButtons = screen.getAllByLabelText('Close badge');
const closeButtons = screen.getAllByLabelText(enUS.design.Accessibility.closeBadge);
fireEvent.click(closeButtons[0]);
expect(handleChange).toHaveBeenCalledWith(['2']);
});
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import type { IWorkbookData, Workbook } from '@univerjs/core';
import type { IDocumentData, IWorkbookData, Workbook } from '@univerjs/core';
import type { IBoundRectNoAngle, IViewportInfo } from '../../../basics/vector2';
import {
BooleanNumber,
@@ -41,7 +41,13 @@ import { Engine } from '../../../engine';
import { MAIN_VIEW_PORT_KEY, Scene } from '../../../scene';
import { Viewport } from '../../../viewport';
import { SHEET_VIEWPORT_KEY } from '../interfaces';
import { convertTransformToOffsetX, convertTransformToOffsetY, SpreadsheetSkeleton } from '../sheet.render-skeleton';
import {
convertTransformToOffsetX,
convertTransformToOffsetY,
getShrinkToFitScale,
scaleDocumentDataForShrinkToFit,
SpreadsheetSkeleton,
} from '../sheet.render-skeleton';
import { Spreadsheet } from '../spreadsheet';
const workbookDataFactory = (): IWorkbookData => ({
@@ -69,6 +75,11 @@ const workbookDataFactory = (): IWorkbookData => ({
ff: 'Arial',
cl: { rgb: '#111111' },
},
'style-shrink': {
stf: BooleanNumber.TRUE,
fs: 12,
ff: 'Arial',
},
},
sheets: {
'sheet-1': {
@@ -94,6 +105,7 @@ const workbookDataFactory = (): IWorkbookData => ({
0: { v: 'A1' },
1: { v: 'very-long-text-for-overflow-path', s: 'style-bg-border' },
2: { v: 'wrapped line text', s: 'style-bg-border' },
3: { v: 'very long text that must shrink to fit', s: 'style-shrink' },
4: { s: 'style-bg-border', custom: { key: 'value' } },
},
1: {
@@ -243,6 +255,46 @@ describe('spreadsheet integration', () => {
document.body.innerHTML = '';
});
it('calculates a shrink scale with a one-point minimum font size', () => {
expect(getShrinkToFitScale(80, 100, 12)).toBe(1);
expect(getShrinkToFitScale(200, 100, 12)).toBe(0.5);
expect(getShrinkToFitScale(2400, 100, 12)).toBeCloseTo(1 / 12);
});
it('scales cloned rich-text font sizes without mutating the stored document', () => {
const document = {
id: 'rich-text',
body: {
dataStream: 'Univer\r\n',
textRuns: [{ st: 0, ed: 6, ts: { fs: 20 } }],
},
documentStyle: { textStyle: { fs: 12 } },
} as IDocumentData;
const scaled = scaleDocumentDataForShrinkToFit(document, 0.5, 10);
expect(scaled).not.toBe(document);
expect(scaled.documentStyle.textStyle?.fs).toBe(6);
expect(scaled.body?.textRuns?.[0].ts?.fs).toBe(10);
expect(document.documentStyle.textStyle?.fs).toBe(12);
expect(document.body?.textRuns?.[0].ts?.fs).toBe(20);
});
it('applies shrink to fit while building the font cache', () => {
const { skeleton, workbook } = fixture;
const worksheet = workbook.getActiveSheet()!;
const cell = worksheet.getCell(0, 3)!;
const style = worksheet.getComposedCellStyleByCellData(0, 3, cell)!;
vi.spyOn(FontCache, 'getMeasureText').mockReturnValue({ width: 240 } as TextMetrics);
skeleton._setFontStylesCache(0, 3, cell, style);
const fontCache = skeleton.getFont(0, 3)!;
expect(fontCache.shrinkScale).toBeCloseTo(68 / 240);
expect(fontCache.fontString).toContain(`${12 * 68 / 240}pt`);
expect(style.fs).toBe(12);
});
it('builds sheet skeleton cache through visible viewport and style/layout calculations', () => {
const { skeleton, scene, cacheCanvas } = fixture;
const vpInfo = createViewportInfo(scene, cacheCanvas);
@@ -55,6 +55,7 @@ export interface IFontCacheItem {
displayText?: string;
resolvedHorizontalAlign?: HorizontalAlign;
textFitsCurrentCell?: boolean;
shrinkScale?: number;
}
type colorString = string;
@@ -16,7 +16,6 @@
import type {
BorderStyleTypes,
DocumentDataModel,
IBorderStyleData,
ICellData,
ICellDataForSheetInterceptor,
@@ -24,6 +23,7 @@ import type {
ICellWithCoord,
IColAutoWidthInfo,
IColumnRange,
IDocumentData,
IGetRowColByPosOptions,
IPaddingData,
IRange,
@@ -46,6 +46,7 @@ import {
BooleanNumber,
CellValueType,
DEFAULT_STYLES,
DocumentDataModel,
extractPureTextFromCell,
getColorStyle,
getDisplayValueFromCell,
@@ -114,6 +115,34 @@ export const DEFAULT_PADDING_DATA = {
export const RENDER_RAW_FORMULA_KEY = 'RENDER_RAW_FORMULA';
export function getShrinkToFitScale(contentWidth: number, availableWidth: number, fontSize: number): number {
if (contentWidth <= availableWidth || contentWidth <= 0 || availableWidth <= 0 || fontSize <= 0) {
return 1;
}
return Math.max(1 / fontSize, availableWidth / contentWidth);
}
export function scaleDocumentDataForShrinkToFit(documentData: IDocumentData, scale: number, fallbackFontSize: number): IDocumentData {
const scaled = Tools.deepClone(documentData);
const defaultTextStyle = scaled.documentStyle.textStyle ?? {};
const defaultFontSize = defaultTextStyle.fs ?? fallbackFontSize;
scaled.documentStyle.textStyle = {
...defaultTextStyle,
fs: defaultFontSize * scale,
};
scaled.body?.textRuns?.forEach((textRun) => {
const textStyle = textRun.ts ?? {};
textRun.ts = {
...textStyle,
fs: (textStyle.fs ?? defaultFontSize) * scale,
};
});
return scaled;
}
function getResolvedRenderHorizontalAlign(
horizontalAlign: HorizontalAlign,
cellData: Nullable<ICellDataForSheetInterceptor>
@@ -1554,6 +1583,49 @@ export class SpreadsheetSkeleton extends SheetSkeleton {
}
}
private _applyShrinkToFit(row: number, col: number, fontCache: IFontCacheItem, style: IStyleData): void {
if (style.stf !== BooleanNumber.TRUE) {
return;
}
const cellInfo = this.getCellWithCoordByIndex(row, col, false);
const startX = cellInfo.isMergedMainCell ? cellInfo.mergeInfo.startX : cellInfo.startX;
const endX = cellInfo.isMergedMainCell ? cellInfo.mergeInfo.endX : cellInfo.endX;
const padding = style.pd ?? DEFAULT_PADDING_DATA;
const extension = fontCache.cellData?.fontRenderExtension;
const availableWidth = endX - startX
- (padding.l ?? DEFAULT_PADDING_DATA.l)
- (padding.r ?? DEFAULT_PADDING_DATA.r)
- (extension?.leftOffset ?? 0)
- (extension?.rightOffset ?? 0);
const fallbackFontSize = style.fs ?? DEFAULT_STYLES.fs;
const contentWidth = fontCache.documentSkeleton
? (getDocsSkeletonPageSize(fontCache.documentSkeleton, fontCache.vertexAngle) ?? { width: 0 }).width
: FontCache.getMeasureText(
fontCache.displayText ?? getDisplayValueFromCell(fontCache.cellData),
fontCache.fontString
).width;
const scale = getShrinkToFitScale(contentWidth, availableWidth, fallbackFontSize);
if (scale >= 1) {
return;
}
fontCache.shrinkScale = scale;
if (fontCache.documentSkeleton) {
const snapshot = fontCache.documentSkeleton.getViewModel().getDataModel().getSnapshot();
const documentModel = new DocumentDataModel(scaleDocumentDataForShrinkToFit(snapshot, scale, fallbackFontSize));
const documentSkeleton = DocumentSkeleton.create(new DocumentViewModel(documentModel), this._localeService);
documentSkeleton.calculate();
fontCache.documentSkeleton = documentSkeleton;
} else {
fontCache.fontString = getFontStyleString({
...style,
fs: fallbackFontSize * scale,
}).fontCache;
}
}
_setFontStylesCache(row: number, col: number, cellData: Nullable<ICellDataForSheetInterceptor>, style: IStyleData, hasMergeData = true) {
if (isNullCell(cellData)) return;
@@ -1620,6 +1692,7 @@ export class SpreadsheetSkeleton extends SheetSkeleton {
}
const fontCacheItem = config as IFontCacheItem;
setRenderTextCache(fontCacheItem, cellData);
this._applyShrinkToFit(row, col, fontCacheItem, style);
this._calculateOverflowCell(row, col, fontCacheItem, hasMergeData);
this._stylesCache.fontMatrix.setValue(row, col, fontCacheItem);
}
@@ -24,6 +24,7 @@ import {
FontFamilySelectorMenuItemFactory,
HorizontalAlignMenuItemFactory,
ItalicMenuItemFactory,
ShrinkToFitMenuItemFactory,
StrikeThroughMenuItemFactory,
TextColorSelectorMenuItemFactory,
TextRotateMenuItemFactory,
@@ -105,4 +106,13 @@ describe('advanced menu state streams', () => {
expect(await firstValueFrom(wrap.value$!.pipe(take(1)))).not.toBeUndefined();
expect(await firstValueFrom(rotate.value$!.pipe(take(1)))).not.toBeUndefined();
});
it('exposes shrink to fit as an activated ribbon button', async () => {
const item = get(Injector).invoke(ShrinkToFitMenuItemFactory);
expect(item.id).toBe('sheet.command.set-shrink-to-fit');
expect(item.icon).toBe('ShrinkToFitIcon');
expect(await firstValueFrom(item.activated$!.pipe(take(1)))).toBe(false);
expect(await firstValueFrom(item.disabled$!.pipe(take(1)))).toBeTypeOf('boolean');
});
});
+35
View File
@@ -55,6 +55,7 @@ import {
SetSelectedColsVisibleCommand,
SetSelectedRowsVisibleCommand,
SetSelectionsOperation,
SetShrinkToFitCommand,
SetTextRotationCommand,
SetTextWrapCommand,
SetVerticalTextAlignCommand,
@@ -836,6 +837,40 @@ export function WrapTextMenuItemFactory(accessor: IAccessor): IMenuSelectorItem<
};
}
export function ShrinkToFitMenuItemFactory(accessor: IAccessor): IMenuButtonItem<LocaleKey> {
const commandService = accessor.get(ICommandService);
const univerInstanceService = accessor.get(IUniverInstanceService);
const selectionManagerService = accessor.get(SheetsSelectionsService);
return {
id: SetShrinkToFitCommand.id,
type: MenuItemType.BUTTON,
icon: 'ShrinkToFitIcon',
title: 'sheets-ui.toolbar.shrinkToFit',
tooltip: 'sheets-ui.toolbar.shrinkToFit',
activated$: deriveStateFromActiveSheet$(univerInstanceService, false, ({ worksheet }) => new Observable<boolean>((subscriber) => {
const update = () => {
const primary = selectionManagerService.getCurrentLastSelection()?.primary;
subscriber.next(primary != null && worksheet.getComposedCellStyle(primary.startRow, primary.startColumn)?.stf === BooleanNumber.TRUE);
};
const disposable = commandService.onCommandExecuted((command) => {
if ([SetRangeValuesMutation.id, SetSelectionsOperation.id, SetWorksheetActiveOperation.id].includes(command.id)) {
update();
}
});
update();
return disposable.dispose;
})),
disabled$: getCurrentRangeDisable$(accessor, {
workbookTypes: [WorkbookEditablePermission],
worksheetTypes: [WorksheetEditPermission, WorksheetSetCellStylePermission],
rangeTypes: [RangeProtectionPermissionEditPoint],
}),
hidden$: getMenuHiddenObservable(accessor, UniverInstanceType.UNIVER_SHEET),
};
}
export const TEXT_ROTATE_CHILDREN = [
{
label: 'sheets-ui.textRotate.none',
+8 -2
View File
@@ -41,6 +41,7 @@ import {
SetRowHeightCommand,
SetSelectedColsVisibleCommand,
SetSelectedRowsVisibleCommand,
SetShrinkToFitCommand,
SetTabColorCommand,
SetTextRotationCommand,
SetTextWrapCommand,
@@ -154,6 +155,7 @@ import {
SetRowHeightMenuItemFactory,
ShowColMenuItemFactory,
ShowRowMenuItemFactory,
ShrinkToFitMenuItemFactory,
StrikeThroughMenuItemFactory,
TextColorSelectorMenuItemFactory,
TextRotateMenuItemFactory,
@@ -264,12 +266,16 @@ export const menuSchema: MenuSchemaType = {
order: 6,
menuItemFactory: WrapTextMenuItemFactory,
},
[SetTextRotationCommand.id]: {
[SetShrinkToFitCommand.id]: {
order: 7,
menuItemFactory: ShrinkToFitMenuItemFactory,
},
[SetTextRotationCommand.id]: {
order: 8,
menuItemFactory: TextRotateMenuItemFactory,
},
[AddWorksheetMergeCommand.id]: {
order: 8,
order: 9,
menuItemFactory: CellMergeMenuItemFactory,
[AddWorksheetMergeAllCommand.id]: {
order: 0,
@@ -16,6 +16,8 @@
import type { ReactElement } from 'react';
import { Injector, LocaleService } from '@univerjs/core';
import { ConfigProvider } from '@univerjs/design';
import enUS from '@univerjs/design/locale/en-US';
import { ContextMenuService, IContextMenuService, RediContext } from '@univerjs/ui';
import { act } from 'react';
import { createRoot } from 'react-dom/client';
@@ -60,7 +62,9 @@ function renderWithDependencies(element: ReactElement) {
act(() => {
root.render(
<RediContext.Provider value={{ injector }}>
{element}
<ConfigProvider locale={enUS.design} mountContainer={container}>
{element}
</ConfigProvider>
</RediContext.Provider>
);
});
@@ -91,7 +95,7 @@ function setInputText(input: HTMLInputElement, value: string) {
}
function clickIncrement(container: HTMLElement) {
const incrementButton = container.querySelector('[aria-label="increment"]');
const incrementButton = container.querySelector(`[aria-label="${enUS.design.Accessibility.increment}"]`);
if (!(incrementButton instanceof HTMLElement)) {
throw new TypeError('Increment button not found');
}
@@ -43,6 +43,7 @@ import {
SetHorizontalTextAlignCommand,
SetItalicCommand,
SetOverlineCommand,
SetShrinkToFitCommand,
SetStrikeThroughCommand,
SetStyleCommand,
SetTextColorCommand,
@@ -76,6 +77,7 @@ describe("Test commands used for updating cells' styles", () => {
commandService.registerCommand(SetVerticalTextAlignCommand);
commandService.registerCommand(SetHorizontalTextAlignCommand);
commandService.registerCommand(SetTextWrapCommand);
commandService.registerCommand(SetShrinkToFitCommand);
commandService.registerCommand(SetTextRotationCommand);
commandService.registerCommand(SetStyleCommand);
commandService.registerCommand(SetRangeValuesMutation);
@@ -688,6 +690,66 @@ describe("Test commands used for updating cells' styles", () => {
});
});
describe('shrink to fit', () => {
it('changes shrink to fit with undo and redo', async () => {
const selectionManager = get(SheetsSelectionsService);
selectionManager.addSelections([
{
range: { startRow: 0, startColumn: 0, endColumn: 0, endRow: 0, rangeType: RANGE_TYPE.NORMAL },
primary: null,
style: null,
},
]);
const getShrinkToFit = () => get(IUniverInstanceService)
.getUniverSheetInstance('test')
?.getSheetBySheetId('sheet1')
?.getComposedCellStyle(0, 0)
?.stf;
expect(await commandService.executeCommand(SetShrinkToFitCommand.id, {
value: BooleanNumber.TRUE,
})).toBeTruthy();
expect(getShrinkToFit()).toBe(BooleanNumber.TRUE);
expect(await commandService.executeCommand(UndoCommand.id)).toBeTruthy();
expect(getShrinkToFit()).toBeUndefined();
expect(await commandService.executeCommand(RedoCommand.id)).toBeTruthy();
expect(getShrinkToFit()).toBe(BooleanNumber.TRUE);
});
it('toggles shrink to fit when no value is provided', async () => {
get(SheetsSelectionsService).addSelections([
{
range: { startRow: 0, startColumn: 0, endColumn: 0, endRow: 0, rangeType: RANGE_TYPE.NORMAL },
primary: {
startRow: 0,
startColumn: 0,
endRow: 0,
endColumn: 0,
actualRow: 0,
actualColumn: 0,
isMerged: false,
isMergedMainCell: false,
},
style: null,
},
]);
const getShrinkToFit = () => get(IUniverInstanceService)
.getUniverSheetInstance('test')
?.getSheetBySheetId('sheet1')
?.getComposedCellStyle(0, 0)
?.stf;
expect(await commandService.executeCommand(SetShrinkToFitCommand.id)).toBeTruthy();
expect(getShrinkToFit()).toBe(BooleanNumber.TRUE);
expect(await commandService.executeCommand(SetShrinkToFitCommand.id)).toBeTruthy();
expect(getShrinkToFit()).toBe(BooleanNumber.FALSE);
});
});
describe('text rotation', () => {
describe('correct situations', () => {
it('will change text rotation when there is a selected range', async () => {
@@ -62,7 +62,7 @@ export interface ISetStyleCommandParams<T> extends ISetStyleCommonParams {
style: IStyleTypeValue<T>;
}
export const AFFECT_LAYOUT_STYLES = ['ff', 'fs', 'tr', 'tb'];
export const AFFECT_LAYOUT_STYLES = ['ff', 'fs', 'stf', 'tr', 'tb'];
/**
* The command to set cell style.
@@ -563,6 +563,40 @@ export const SetTextWrapCommand: ICommand<ISetTextWrapCommandParams> = {
},
};
export interface ISetShrinkToFitCommandParams extends ISetStyleCommonParams {
value?: BooleanNumber;
}
export const SetShrinkToFitCommand: ICommand<ISetShrinkToFitCommandParams> = {
type: CommandType.COMMAND,
id: 'sheet.command.set-shrink-to-fit',
handler: (accessor, params) => {
let value = params?.value;
if (value === undefined) {
const selection = accessor.get(SheetsSelectionsService).getCurrentLastSelection();
const target = getSheetCommandTarget(accessor.get(IUniverInstanceService));
if (!selection?.primary || !target) {
return false;
}
const { actualRow, actualColumn } = selection.primary;
value = target.worksheet.getComposedCellStyle(actualRow, actualColumn)?.stf === BooleanNumber.TRUE
? BooleanNumber.FALSE
: BooleanNumber.TRUE;
}
return accessor.get(ICommandService).syncExecuteCommand(SetStyleCommand.id, {
unitId: params?.unitId,
subUnitId: params?.subUnitId,
range: params?.range,
style: {
type: 'stf',
value,
},
} as ISetStyleCommandParams<BooleanNumber>);
},
};
export interface ISetTextRotationCommandParams extends ISetStyleCommonParams {
value: number | string;
}
@@ -89,6 +89,7 @@ import {
ResetTextColorCommand,
SetBackgroundColorCommand,
SetHorizontalTextAlignCommand,
SetShrinkToFitCommand,
SetStyleCommand,
SetTextColorCommand,
SetTextRotationCommand,
@@ -305,6 +306,7 @@ export class BasicWorksheetController extends Disposable implements IDisposable
SetSpecificColsVisibleCommand,
SetSpecificRowsVisibleCommand,
SetStyleCommand,
SetShrinkToFitCommand,
SetTabColorCommand,
SetTabColorMutation,
SetTextColorCommand,
@@ -54,6 +54,7 @@ import {
SetRangeCustomMetadataCommand,
SetRangeValuesCommand,
SetRangeValuesMutation,
SetShrinkToFitCommand,
SetStyleCommand,
SetTextRotationCommand,
SetTextWrapCommand,
@@ -100,6 +101,7 @@ describe('Test FRange', () => {
commandService.registerCommand(SetRangeValuesCommand);
commandService.registerCommand(SetRangeValuesMutation);
commandService.registerCommand(SetStyleCommand);
commandService.registerCommand(SetShrinkToFitCommand);
commandService.registerCommand(SetVerticalTextAlignCommand);
commandService.registerCommand(SetHorizontalTextAlignCommand);
commandService.registerCommand(SetTextWrapCommand);
@@ -685,6 +687,16 @@ describe('Test FRange', () => {
expect(getStyleByPosition(0, 0, 0, 0)?.tb).toBe(WrapStrategy.CLIP);
});
it('gets and sets shrink to fit', () => {
const range = univerAPI.getActiveWorkbook()!.getActiveSheet()!.getRange('A1');
expect(range.getShrinkToFit()).toBe(false);
expect(range.setShrinkToFit(true)).toBe(range);
expect(range.getShrinkToFit()).toBe(true);
expect(range.setShrinkToFit(false)).toBe(range);
expect(range.getShrinkToFit()).toBe(false);
});
// #region Merge cells
it('test Merge', async () => {
let hasError = false;
+20
View File
@@ -39,6 +39,7 @@ import type {
ISetRangeCustomMetadataCommandParams,
ISetRangeValuesCommandParams,
ISetSelectionsOperationParams,
ISetShrinkToFitCommandParams,
ISetStyleCommandParams,
ISetTextRotationCommandParams,
ISetTextWrapCommandParams,
@@ -87,6 +88,7 @@ import {
SetRangeCustomMetadataCommand,
SetRangeValuesCommand,
SetSelectionsOperation,
SetShrinkToFitCommand,
SetStyleCommand,
SetTextRotationCommand,
SetTextWrapCommand,
@@ -945,6 +947,12 @@ export class FRange extends FBaseInitialable {
return this._worksheet.getRange(this._range).getWrap() === BooleanNumber.TRUE;
}
/** Gets whether the top-left cell shrinks its font size to fit the cell width. */
getShrinkToFit(): boolean {
const { startRow, startColumn } = this._range;
return this._worksheet.getComposedCellStyle(startRow, startColumn)?.stf === BooleanNumber.TRUE;
}
/**
* Gets whether text wrapping is enabled for cells in the range.
* @returns {boolean[][]} A two-dimensional array of whether text wrapping is enabled for each cell in the range.
@@ -1449,6 +1457,18 @@ export class FRange extends FBaseInitialable {
return this;
}
/** Sets whether cells shrink their font size to fit the cell width. */
setShrinkToFit(enabled: boolean): FRange {
this._commandService.syncExecuteCommand(SetShrinkToFitCommand.id, {
unitId: this._workbook.getUnitId(),
subUnitId: this._worksheet.getSheetId(),
range: this._range,
value: enabled ? BooleanNumber.TRUE : BooleanNumber.FALSE,
} as ISetShrinkToFitCommandParams);
return this;
}
/**
* Sets the text wrapping strategy for the cells in the range.
* @param {WrapStrategy} strategy The text wrapping strategy
+2
View File
@@ -221,6 +221,7 @@ export {
SetHorizontalTextAlignCommand,
SetItalicCommand,
SetOverlineCommand,
SetShrinkToFitCommand,
SetStrikeThroughCommand,
SetStyleCommand,
SetTextColorCommand,
@@ -234,6 +235,7 @@ export type {
ISetFontFamilyCommandParams,
ISetFontSizeCommandParams,
ISetHorizontalTextAlignCommandParams,
ISetShrinkToFitCommandParams,
ISetStyleCommandParams,
ISetTextRotationCommandParams,
ISetTextWrapCommandParams,