mirror of
https://github.com/dream-num/univer.git
synced 2026-09-19 02:18:42 +08:00
feat(sheets): support shrink to fit (#7271)
This commit is contained in:
@@ -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']);
|
||||
});
|
||||
|
||||
+54
-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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user