diff --git a/packages/core/src/types/interfaces/i-style-data.ts b/packages/core/src/types/interfaces/i-style-data.ts index c2716ea9ab..ef6ea79ff4 100644 --- a/packages/core/src/types/interfaces/i-style-data.ts +++ b/packages/core/src/types/interfaces/i-style-data.ts @@ -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()([ 'cl', 'va', 'n', + 'stf', 'tr', 'td', 'ht', diff --git a/packages/design/src/components/badge/__tests__/index.spec.tsx b/packages/design/src/components/badge/__tests__/index.spec.tsx index ae682e9d78..b567c6e7ff 100644 --- a/packages/design/src/components/badge/__tests__/index.spec.tsx +++ b/packages/design/src/components/badge/__tests__/index.spec.tsx @@ -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(Closable Badge); - const closeButton = screen.getByLabelText('Close badge'); + render( + + Closable Badge + + ); + const closeButton = screen.getByLabelText(enUS.design.Accessibility.closeBadge); expect(closeButton).toBeInTheDocument(); fireEvent.click(closeButton); expect(onClose).toHaveBeenCalled(); diff --git a/packages/design/src/components/color-picker/__tests__/index.spec.tsx b/packages/design/src/components/color-picker/__tests__/index.spec.tsx index bfd4848484..b81178768e 100644 --- a/packages/design/src/components/color-picker/__tests__/index.spec.tsx +++ b/packages/design/src/components/color-picker/__tests__/index.spec.tsx @@ -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(); + const { container } = render( + + + + ); const moreLink = Array.from(container.querySelectorAll('a')).find((a) => a.textContent?.includes('更多') || a.textContent?.toLowerCase().includes('more')); if (moreLink) { - moreLink.dispatchEvent(new MouseEvent('click', { bubbles: true })); + fireEvent.click(moreLink); expect(document.body.innerHTML).toContain('univer-grid univer-w-64 univer-gap-2'); } }); diff --git a/packages/design/src/components/dialog/__tests__/index.spec.tsx b/packages/design/src/components/dialog/__tests__/index.spec.tsx index 127736986b..dc3a99230c 100644 --- a/packages/design/src/components/dialog/__tests__/index.spec.tsx +++ b/packages/design/src/components/dialog/__tests__/index.spec.tsx @@ -16,6 +16,7 @@ import { cleanup, fireEvent, render } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; +import 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(content); - getByText(/ok|确定/i).click(); - getByText(/cancel|取消/i).click(); + const { getByText } = render( + + content + + ); + getByText(enUS.design.Confirm.confirm).click(); + getByText(enUS.design.Confirm.cancel).click(); expect(onOk).toHaveBeenCalled(); expect(onCancel).toHaveBeenCalled(); }); diff --git a/packages/design/src/components/gallery/__tests__/index.spec.tsx b/packages/design/src/components/gallery/__tests__/index.spec.tsx index 27643b8fa4..d224b16f31 100644 --- a/packages/design/src/components/gallery/__tests__/index.spec.tsx +++ b/packages/design/src/components/gallery/__tests__/index.spec.tsx @@ -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 {children}; +} + +function renderGallery(props: ComponentProps) { + return render(, { wrapper: LocaleProvider }); +} + afterEach(() => { cleanup(); }); @@ -34,12 +45,12 @@ describe('Gallery', () => { vi.useRealTimers(); }); it('does not render when open is false', () => { - render(); + renderGallery({ images, open: false }); expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); }); it('renders and displays the first image when open', () => { - render(); + 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(); + 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(); + renderGallery({ images, open: true, onOpenChange }); fireEvent.keyDown(window, { key: 'Escape', code: 'Escape' }); expect(onOpenChange).toHaveBeenCalledWith(false); }); it('toolbar buttons have correct aria-labels', () => { - render(); + 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(); + 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(); + 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(); + 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(); + 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(); + const { rerender } = renderGallery({ images, open: true }); expect(screen.getByRole('dialog')).toBeInTheDocument(); rerender(); diff --git a/packages/design/src/components/input-number/__tests__/index.spec.tsx b/packages/design/src/components/input-number/__tests__/index.spec.tsx index faef50f6a0..fb65b83063 100644 --- a/packages/design/src/components/input-number/__tests__/index.spec.tsx +++ b/packages/design/src/components/input-number/__tests__/index.spec.tsx @@ -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( -
- -
+ +
+ +
+
); - 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'); diff --git a/packages/design/src/components/select/__tests__/index.spec.tsx b/packages/design/src/components/select/__tests__/index.spec.tsx index bcbe313748..0dad736d2d 100644 --- a/packages/design/src/components/select/__tests__/index.spec.tsx +++ b/packages/design/src/components/select/__tests__/index.spec.tsx @@ -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(); + render( + + + + ); - const closeButtons = screen.getAllByLabelText('Close badge'); + const closeButtons = screen.getAllByLabelText(enUS.design.Accessibility.closeBadge); fireEvent.click(closeButtons[0]); expect(handleChange).toHaveBeenCalledWith(['2']); }); diff --git a/packages/engine-render/src/components/sheets/__tests__/spreadsheet.integration.spec.ts b/packages/engine-render/src/components/sheets/__tests__/spreadsheet.integration.spec.ts index ed9bc78230..ae9da295d7 100644 --- a/packages/engine-render/src/components/sheets/__tests__/spreadsheet.integration.spec.ts +++ b/packages/engine-render/src/components/sheets/__tests__/spreadsheet.integration.spec.ts @@ -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); diff --git a/packages/engine-render/src/components/sheets/interfaces.ts b/packages/engine-render/src/components/sheets/interfaces.ts index d9a94710fa..c4a34a91f3 100644 --- a/packages/engine-render/src/components/sheets/interfaces.ts +++ b/packages/engine-render/src/components/sheets/interfaces.ts @@ -55,6 +55,7 @@ export interface IFontCacheItem { displayText?: string; resolvedHorizontalAlign?: HorizontalAlign; textFitsCurrentCell?: boolean; + shrinkScale?: number; } type colorString = string; diff --git a/packages/engine-render/src/components/sheets/sheet.render-skeleton.ts b/packages/engine-render/src/components/sheets/sheet.render-skeleton.ts index 0666ec640c..49ed4d9e07 100644 --- a/packages/engine-render/src/components/sheets/sheet.render-skeleton.ts +++ b/packages/engine-render/src/components/sheets/sheet.render-skeleton.ts @@ -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 @@ -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, 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); } diff --git a/packages/sheets-ui/src/menu/__tests__/menu-advanced.spec.ts b/packages/sheets-ui/src/menu/__tests__/menu-advanced.spec.ts index b870f7e974..a8775eaa73 100644 --- a/packages/sheets-ui/src/menu/__tests__/menu-advanced.spec.ts +++ b/packages/sheets-ui/src/menu/__tests__/menu-advanced.spec.ts @@ -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'); + }); }); diff --git a/packages/sheets-ui/src/menu/menu.ts b/packages/sheets-ui/src/menu/menu.ts index 3c92544f09..aee3aeec1e 100644 --- a/packages/sheets-ui/src/menu/menu.ts +++ b/packages/sheets-ui/src/menu/menu.ts @@ -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 { + 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((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', diff --git a/packages/sheets-ui/src/menu/schema.ts b/packages/sheets-ui/src/menu/schema.ts index 2e87d00cd6..554b2ec207 100644 --- a/packages/sheets-ui/src/menu/schema.ts +++ b/packages/sheets-ui/src/menu/schema.ts @@ -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, diff --git a/packages/sheets-ui/src/views/menu-item-input/__tests__/MenuItemInput.spec.tsx b/packages/sheets-ui/src/views/menu-item-input/__tests__/MenuItemInput.spec.tsx index 5bd1ab2893..391b408899 100644 --- a/packages/sheets-ui/src/views/menu-item-input/__tests__/MenuItemInput.spec.tsx +++ b/packages/sheets-ui/src/views/menu-item-input/__tests__/MenuItemInput.spec.tsx @@ -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( - {element} + + {element} + ); }); @@ -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'); } diff --git a/packages/sheets/src/commands/commands/__tests__/set-style.command.spec.ts b/packages/sheets/src/commands/commands/__tests__/set-style.command.spec.ts index ac85974c29..359c7a98cc 100644 --- a/packages/sheets/src/commands/commands/__tests__/set-style.command.spec.ts +++ b/packages/sheets/src/commands/commands/__tests__/set-style.command.spec.ts @@ -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 () => { diff --git a/packages/sheets/src/commands/commands/set-style.command.ts b/packages/sheets/src/commands/commands/set-style.command.ts index 175cf96b6d..754edfe27a 100644 --- a/packages/sheets/src/commands/commands/set-style.command.ts +++ b/packages/sheets/src/commands/commands/set-style.command.ts @@ -62,7 +62,7 @@ export interface ISetStyleCommandParams extends ISetStyleCommonParams { style: IStyleTypeValue; } -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 = { }, }; +export interface ISetShrinkToFitCommandParams extends ISetStyleCommonParams { + value?: BooleanNumber; +} + +export const SetShrinkToFitCommand: ICommand = { + 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); + }, +}; + export interface ISetTextRotationCommandParams extends ISetStyleCommonParams { value: number | string; } diff --git a/packages/sheets/src/controllers/basic-worksheet.controller.ts b/packages/sheets/src/controllers/basic-worksheet.controller.ts index 2cd59b92a8..5b7051bea4 100644 --- a/packages/sheets/src/controllers/basic-worksheet.controller.ts +++ b/packages/sheets/src/controllers/basic-worksheet.controller.ts @@ -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, diff --git a/packages/sheets/src/facade/__tests__/f-range.spec.ts b/packages/sheets/src/facade/__tests__/f-range.spec.ts index 8288a833ee..4cdecf07ef 100644 --- a/packages/sheets/src/facade/__tests__/f-range.spec.ts +++ b/packages/sheets/src/facade/__tests__/f-range.spec.ts @@ -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; diff --git a/packages/sheets/src/facade/f-range.ts b/packages/sheets/src/facade/f-range.ts index fd0b4c1be1..e0871dccc4 100644 --- a/packages/sheets/src/facade/f-range.ts +++ b/packages/sheets/src/facade/f-range.ts @@ -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 diff --git a/packages/sheets/src/index.ts b/packages/sheets/src/index.ts index 01ea3c27e2..181a45bf20 100644 --- a/packages/sheets/src/index.ts +++ b/packages/sheets/src/index.ts @@ -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,