diff --git a/packages/docs-ui/src/components/paragraph-menu/__tests__/paragraph-menu.spec.ts b/packages/docs-ui/src/components/paragraph-menu/__tests__/paragraph-menu.spec.ts index bd8525997e..9ccde808b8 100644 --- a/packages/docs-ui/src/components/paragraph-menu/__tests__/paragraph-menu.spec.ts +++ b/packages/docs-ui/src/components/paragraph-menu/__tests__/paragraph-menu.spec.ts @@ -21,7 +21,7 @@ import React from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import { afterEach, describe, expect, it, vi } from 'vitest'; import * as paragraphMenu from '..'; -import { createParagraphMenuHoverOpenScheduler, getParagraphFormattingRange, getParagraphMenuActiveHeadingCommandId, getParagraphMenuCommand, getParagraphMenuCommandTargetRange, getParagraphMenuHiddenHeadingCommandIds, getParagraphMenuHiddenItemIds, getParagraphMenuIconSizeClass, getParagraphMenuPopupDirection, getParagraphMenuTargetRange, isEmptyParagraphMenuTarget, PARAGRAPH_MENU_HOVER_OPEN_DELAY, shouldShowParagraphSettingMenu, shouldUseInsertBelowRange } from '..'; +import { createParagraphMenuHoverOpenScheduler, getParagraphFormattingRange, getParagraphMenuActiveHeadingCommandId, getParagraphMenuCommand, getParagraphMenuCommandTargetRange, getParagraphMenuHiddenHeadingCommandIds, getParagraphMenuHiddenItemIds, getParagraphMenuIconSizeClass, getParagraphMenuPopupDirection, getParagraphMenuResolvedCommand, getParagraphMenuTargetRange, isEmptyParagraphMenuTarget, PARAGRAPH_MENU_HOVER_OPEN_DELAY, shouldShowParagraphSettingMenu, shouldUseInsertBelowRange } from '..'; import { HorizontalLineCommand } from '../../../commands/commands/doc-horizontal-line.command'; import { SetInlineFormatTextBackgroundColorCommand, SetInlineFormatTextColorCommand } from '../../../commands/commands/inline-format.command'; import { BulletListCommand, InsertBulletListBellowCommand, OrderListCommand } from '../../../commands/commands/list.command'; @@ -168,20 +168,32 @@ describe('ParagraphMenu', () => { }); it('reuses the existing text color selector logic for the colors header action with an A icon', () => { + const registeredIcons = new Map>(); + const componentManager = { + get: (key: string) => registeredIcons.get(key), + register: (key: string, component: React.ComponentType<{ className?: string }>) => { + registeredIcons.set(key, component); + }, + }; const accessor = { - get: () => ({ - get: () => undefined, - register: () => undefined, - }), + get: () => componentManager, } as never; const menuItem = ParagraphMenuTextColorHeaderActionMenuItemFactory(accessor); + const Icon = registeredIcons.get(menuItem.icon as string); expect(menuItem.type).toBe(MenuItemType.BUTTON_SELECTOR); expect(menuItem.id).toBe(SetInlineFormatTextColorCommand.id); expect(menuItem.icon).toBe('HeaderTextColorIcon'); expect(menuItem.tooltip).toBeUndefined(); expect(Array.isArray((menuItem as any).selections)).toBe(true); + expect(Icon).toBeDefined(); + + const markup = renderToStaticMarkup(React.createElement(Icon!, { className: 'header-color-icon' })); + + expect(markup).toContain('>A<'); + expect(markup).toContain('width="1em"'); + expect(markup).toContain('height="1em"'); }); it('reuses the existing background color selector logic for the colors header action with the bucket icon', () => { @@ -424,6 +436,24 @@ describe('ParagraphMenu', () => { }); }); + it('preserves custom color values when resolving paragraph menu commands', () => { + expect(getParagraphMenuResolvedCommand({ + id: SetInlineFormatTextColorCommand.id, + value: '#ff5500', + }, null)).toEqual({ + commandId: SetInlineFormatTextColorCommand.id, + params: { value: '#ff5500' }, + }); + + expect(getParagraphMenuResolvedCommand({ + id: SetInlineFormatTextBackgroundColorCommand.id, + value: 'rgba(255, 140, 81, 0.3)', + }, null)).toEqual({ + commandId: SetInlineFormatTextBackgroundColorCommand.id, + params: { value: 'rgba(255, 140, 81, 0.3)' }, + }); + }); + it('passes the hovered paragraph range to current-paragraph menu commands', () => { const targetRange = { startOffset: 3, endOffset: 3, collapsed: true }; diff --git a/packages/docs-ui/src/components/paragraph-menu/index.tsx b/packages/docs-ui/src/components/paragraph-menu/index.tsx index bde1341044..59bd486ec6 100644 --- a/packages/docs-ui/src/components/paragraph-menu/index.tsx +++ b/packages/docs-ui/src/components/paragraph-menu/index.tsx @@ -270,6 +270,20 @@ export function getParagraphMenuCommand(params: IValueOption, targetRange?: ITex }; } +export function getParagraphMenuResolvedCommand(option: IValueOption, targetRange?: ITextRangeWithStyle | null): { commandId?: string; params?: object } { + const commandId = option.commandId ?? option.id ?? (typeof option.label === 'string' ? option.label : undefined); + + if (!commandId) { + return {}; + } + + return getParagraphMenuCommand({ + ...option, + commandId, + id: option.id ?? commandId, + }, targetRange); +} + function getParagraphMenuType(target: IDocBlockMenuTarget | null | undefined, emptyMode: boolean): string { if (target?.kind === 'table') { return DOC_TABLE_BLOCK_MENU_ID; @@ -681,12 +695,8 @@ export const ParagraphMenu = ({ popup }: { popup: IPopup }) => { docSelectionManagerService.replaceTextRanges([range], false); }; - const executeResolvedCommand = (commandId: string, params?: Record, targetRange?: ITextRangeWithStyle | null) => { - const resolved = getParagraphMenuCommand({ - commandId, - id: commandId, - params, - }, targetRange); + const executeResolvedCommand = (option: IValueOption, targetRange?: ITextRangeWithStyle | null) => { + const resolved = getParagraphMenuResolvedCommand(option, targetRange); if (!resolved.commandId) { return false; @@ -812,7 +822,11 @@ export const ParagraphMenu = ({ popup }: { popup: IPopup }) => { ) { const nextRange = await unwrapActiveBlockRange(); if (commandId !== NormalTextHeadingCommand.id) { - await executeResolvedCommand(commandId, commandParams as Record | undefined, nextRange); + await executeResolvedCommand({ + ...option, + commandId, + params: commandParams as Record | undefined, + }, nextRange); } layoutService.focus(); handleHideMenu(); @@ -827,7 +841,11 @@ export const ParagraphMenu = ({ popup }: { popup: IPopup }) => { if (blockRange) { replaceSelection(blockRange); } - await executeResolvedCommand(commandId, commandParams as Record | undefined, blockRange); + await executeResolvedCommand({ + ...option, + commandId, + params: commandParams as Record | undefined, + }, blockRange); layoutService.focus(); handleHideMenu(); return; @@ -838,7 +856,9 @@ export const ParagraphMenu = ({ popup }: { popup: IPopup }) => { if (targetRange) { replaceSelection(targetRange); } - await executeResolvedCommand(NormalTextHeadingCommand.id, undefined, targetRange); + await executeResolvedCommand({ + id: NormalTextHeadingCommand.id, + }, targetRange); layoutService.focus(); handleHideMenu(); return; @@ -855,11 +875,11 @@ export const ParagraphMenu = ({ popup }: { popup: IPopup }) => { replaceSelection(getParagraphMenuCommandTargetRange(commandId, targetRange, formattingRange)); } - await executeResolvedCommand( + await executeResolvedCommand({ + ...option, commandId, - commandParams as Record | undefined, - getParagraphMenuCommandTargetRange(commandId, targetRange, formattingRange) - ); + params: commandParams as Record | undefined, + }, getParagraphMenuCommandTargetRange(commandId, targetRange, formattingRange)); layoutService.focus(); handleHideMenu(); }; diff --git a/packages/docs-ui/src/controllers/render-controllers/__tests__/zoom.render-controller.spec.ts b/packages/docs-ui/src/controllers/render-controllers/__tests__/zoom.render-controller.spec.ts index e9203aaba7..1ea476c376 100644 --- a/packages/docs-ui/src/controllers/render-controllers/__tests__/zoom.render-controller.spec.ts +++ b/packages/docs-ui/src/controllers/render-controllers/__tests__/zoom.render-controller.spec.ts @@ -16,7 +16,7 @@ import { DocumentFlavor } from '@univerjs/core'; import { describe, expect, it } from 'vitest'; -import { getRuntimeDocZoomRatio, shouldHandleDocWheelZoom } from '../zoom.render-controller'; +import { shouldHandleDocWheelZoom } from '../zoom.render-controller'; describe('DocZoomRenderController', () => { it('handles wheel zoom intent for focused modern docs', () => { @@ -28,14 +28,4 @@ describe('DocZoomRenderController', () => { expect(shouldHandleDocWheelZoom({ ctrlKey: false, metaKey: false }, true, DocumentFlavor.TRADITIONAL)).toBe(false); expect(shouldHandleDocWheelZoom({ ctrlKey: true, metaKey: false }, false, DocumentFlavor.TRADITIONAL)).toBe(false); }); - - it('uses a larger runtime zoom default for modern docs without persisting it', () => { - expect(getRuntimeDocZoomRatio(undefined, DocumentFlavor.MODERN)).toBe(1.2); - expect(getRuntimeDocZoomRatio(undefined, DocumentFlavor.TRADITIONAL)).toBe(1); - }); - - it('keeps explicit document zoom settings ahead of runtime defaults', () => { - expect(getRuntimeDocZoomRatio(0.85, DocumentFlavor.MODERN)).toBe(0.85); - expect(getRuntimeDocZoomRatio(1.5, DocumentFlavor.TRADITIONAL)).toBe(1.5); - }); }); diff --git a/packages/docs-ui/src/controllers/render-controllers/zoom.render-controller.ts b/packages/docs-ui/src/controllers/render-controllers/zoom.render-controller.ts index 04b2536a52..d0ed7788a8 100644 --- a/packages/docs-ui/src/controllers/render-controllers/zoom.render-controller.ts +++ b/packages/docs-ui/src/controllers/render-controllers/zoom.render-controller.ts @@ -17,6 +17,8 @@ import type { DocumentDataModel, ICommandInfo, Workbook } from '@univerjs/core'; import type { IRenderContext, IRenderModule, IWheelEvent } from '@univerjs/engine-render'; +import type { IDocPageSetupCommandParams } from '../../commands/commands/doc-page-setup.command'; +import type { ISetDocZoomRatioOperationParams } from '../../commands/operations/set-doc-zoom-ratio.operation'; import { Disposable, DOCS_NORMAL_EDITOR_UNIT_ID_KEY, @@ -37,6 +39,7 @@ import { SetDocZoomRatioCommand } from '../../commands/commands/set-doc-zoom-rat import { SwitchDocModeCommand } from '../../commands/commands/switch-doc-mode.command'; import { SetDocZoomRatioOperation } from '../../commands/operations/set-doc-zoom-ratio.operation'; import { DocPageLayoutService } from '../../services/doc-page-layout.service'; +import { DEFAULT_MODERN_DOC_ZOOM_RATIO, getDocEffectiveZoomRatio } from '../../services/doc-zoom'; import { IEditorService } from '../../services/editor/editor-manager.service'; export function shouldHandleDocWheelZoom( @@ -47,14 +50,6 @@ export function shouldHandleDocWheelZoom( return focusingDoc && (event.ctrlKey || event.metaKey); } -export function getDefaultDocZoomRatio(documentFlavor?: DocumentFlavor): number { - return documentFlavor === DocumentFlavor.MODERN ? 1.2 : 1; -} - -export function getRuntimeDocZoomRatio(savedZoomRatio?: number, documentFlavor?: DocumentFlavor): number { - return savedZoomRatio ?? getDefaultDocZoomRatio(documentFlavor); -} - export class DocZoomRenderController extends Disposable implements IRenderModule { private _isSheetEditor = false; private _initTimer: number; @@ -80,12 +75,10 @@ export class DocZoomRenderController extends Disposable implements IRenderModule const sheetRenderer = currentSheet && this._renderManagerService.getRenderById(currentSheet.getUnitId()); // TODO: do not use setTimeout. this._initTimer = window.setTimeout(() => { - const documentModel = this._univerInstanceService.getCurrentUniverDocInstance(); const zoomRatio = sheetRenderer && this._isSheetEditor ? sheetRenderer.scene.scaleX - : documentModel - ? this._getRuntimeZoomRatio(documentModel) - : 1; + : getDocEffectiveZoomRatio(this._context.unit); + this.updateViewZoom(zoomRatio, true); }, 20); @@ -111,9 +104,7 @@ export class DocZoomRenderController extends Disposable implements IRenderModule this._updateTimer = window.setTimeout(() => { const currentSheet = this._univerInstanceService.getCurrentUnitOfType(UniverInstanceType.UNIVER_SHEET); const sheetRenderer = currentSheet && this._renderManagerService.getRenderById(currentSheet.getUnitId()); - const zoomRatio = !this._isSheetEditor - ? this._getRuntimeZoomRatio(documentModel) - : sheetRenderer?.scene.scaleX || 1; + const zoomRatio = !this._isSheetEditor ? getDocEffectiveZoomRatio(documentModel) : sheetRenderer?.scene.scaleX || 1; this.updateViewZoom(zoomRatio, false); }); @@ -121,35 +112,29 @@ export class DocZoomRenderController extends Disposable implements IRenderModule } private _initCommandExecutedListener() { - const updateCommandList = [SetDocZoomRatioOperation.id, SwitchDocModeCommand.id, DocPageSetupCommand.id]; + const updateCommandList = [SetDocZoomRatioOperation.id]; this.disposeWithMe(this._commandService.onCommandExecuted((command: ICommandInfo) => { - if (!updateCommandList.includes(command.id)) { - return; - } - - const unitId = (command.params as { unitId?: string; documentId?: string } | undefined)?.unitId - ?? (command.params as { unitId?: string; documentId?: string } | undefined)?.documentId - ?? this._context.unitId; - - if (unitId !== this._context.unitId) { - return; - } - - const documentModel = this._context.unit; - - if (command.id === SetDocZoomRatioOperation.id) { - const zoomRatio = documentModel.zoomRatio || 1; + if (updateCommandList.includes(command.id) && (command.params as ISetDocZoomRatioOperationParams).unitId === this._context.unitId) { + const documentModel = this._context.unit; + const zoomRatio = getDocEffectiveZoomRatio(documentModel); this.updateViewZoom(zoomRatio); - return; } - - if (documentModel.getSettings()?.zoomRatio != null) { - return; - } - - this.updateViewZoom(this._getRuntimeZoomRatio(documentModel)); })); + + this.disposeWithMe( + this._commandService.beforeCommandExecuted((command: ICommandInfo) => { + const shouldResetZoom = command.id === SwitchDocModeCommand.id || + (command.id === DocPageSetupCommand.id && (command.params as IDocPageSetupCommandParams | undefined)?.documentFlavor === DocumentFlavor.MODERN); + + if (shouldResetZoom) { + this._commandService.executeCommand(SetDocZoomRatioCommand.id, { + zoomRatio: DEFAULT_MODERN_DOC_ZOOM_RATIO, + documentId: this._context.unitId, + }); + } + }) + ); } updateViewZoom(zoomRatio: number, needRefreshSelection = true) { @@ -186,7 +171,7 @@ export class DocZoomRenderController extends Disposable implements IRenderModule return; } - const currentRatio = this._getRuntimeZoomRatio(documentModel); + const currentRatio = getDocEffectiveZoomRatio(documentModel); const nextRatio = getNextWheelZoomRatio(currentRatio, e); this._commandService.executeCommand(SetDocZoomRatioCommand.id, { @@ -198,11 +183,4 @@ export class DocZoomRenderController extends Disposable implements IRenderModule }) ); } - - private _getRuntimeZoomRatio(documentModel: DocumentDataModel): number { - return getRuntimeDocZoomRatio( - documentModel.getSettings()?.zoomRatio, - documentModel.getSnapshot().documentStyle?.documentFlavor - ); - } } diff --git a/packages/docs-ui/src/menu/paragraph-menu.ts b/packages/docs-ui/src/menu/paragraph-menu.ts index 56cb617b1b..d754c13e52 100644 --- a/packages/docs-ui/src/menu/paragraph-menu.ts +++ b/packages/docs-ui/src/menu/paragraph-menu.ts @@ -122,6 +122,8 @@ function HeaderTextColorIcon({ className, extend }: { className: string; extend? { className, viewBox: '0 0 24 24', + width: '1em', + height: '1em', fill: 'none', 'aria-hidden': true, }, @@ -246,7 +248,7 @@ export const EmptyParagraphBulletListMenuItemFactory = createEmptyParagraphButto export const EmptyParagraphCheckListMenuItemFactory = createEmptyParagraphButtonFactory(CheckListCommand, 'TodoListDoubleIcon', 'docs-ui.rightClick.checkList'); export const EmptyParagraphHorizontalLineMenuItemFactory = createEmptyParagraphButtonFactory(HorizontalLineCommand, 'ReduceIcon', 'docs-ui.toolbar.horizontalLine'); -export const CopyCurrentParagraphMenuItemFactory = (accessor: IAccessor): IMenuItem => { +export const CopyCurrentParagraphMenuItemFactory = (_accessor: IAccessor): IMenuItem => { return { id: DocCopyCurrentParagraphCommand.id, type: MenuItemType.BUTTON, @@ -255,7 +257,7 @@ export const CopyCurrentParagraphMenuItemFactory = (accessor: IAccessor): IMenuI }; }; -export const CutCurrentParagraphMenuItemFactory = (accessor: IAccessor): IMenuItem => { +export const CutCurrentParagraphMenuItemFactory = (_accessor: IAccessor): IMenuItem => { return { id: DocCutCurrentParagraphCommand.id, type: MenuItemType.BUTTON, @@ -264,7 +266,7 @@ export const CutCurrentParagraphMenuItemFactory = (accessor: IAccessor): IMenuIt }; }; -export const DeleteCurrentParagraphMenuItemFactory = (accessor: IAccessor): IMenuItem => { +export const DeleteCurrentParagraphMenuItemFactory = (_accessor: IAccessor): IMenuItem => { return { id: DeleteCurrentParagraphCommand.id, type: MenuItemType.BUTTON, @@ -273,7 +275,7 @@ export const DeleteCurrentParagraphMenuItemFactory = (accessor: IAccessor): IMen }; }; -export const InsertBulletListBellowMenuItemFactory = (accessor: IAccessor): IMenuItem => { +export const InsertBulletListBellowMenuItemFactory = (_accessor: IAccessor): IMenuItem => { return { id: InsertBulletListBellowCommand.id, type: MenuItemType.BUTTON, @@ -283,7 +285,7 @@ export const InsertBulletListBellowMenuItemFactory = (accessor: IAccessor): IMen }; }; -export const InsertOrderListBellowMenuItemFactory = (accessor: IAccessor): IMenuItem => { +export const InsertOrderListBellowMenuItemFactory = (_accessor: IAccessor): IMenuItem => { return { id: InsertOrderListBellowCommand.id, type: MenuItemType.BUTTON, @@ -293,7 +295,7 @@ export const InsertOrderListBellowMenuItemFactory = (accessor: IAccessor): IMenu }; }; -export const InsertCheckListBellowMenuItemFactory = (accessor: IAccessor): IMenuItem => { +export const InsertCheckListBellowMenuItemFactory = (_accessor: IAccessor): IMenuItem => { return { id: InsertCheckListBellowCommand.id, type: MenuItemType.BUTTON, @@ -303,7 +305,7 @@ export const InsertCheckListBellowMenuItemFactory = (accessor: IAccessor): IMenu }; }; -export const InsertHorizontalLineBellowMenuItemFactory = (accessor: IAccessor): IMenuItem => { +export const InsertHorizontalLineBellowMenuItemFactory = (_accessor: IAccessor): IMenuItem => { return { id: InsertHorizontalLineBellowCommand.id, type: MenuItemType.BUTTON, @@ -389,6 +391,8 @@ function TextColorSwatchIcon(props: { className?: string; color: string }) { { className, viewBox: '0 0 24 24', + width: '1em', + height: '1em', fill: 'none', 'aria-hidden': true, }, @@ -420,6 +424,8 @@ function BackgroundColorSwatchIcon(props: { className?: string; color: string }) { className, viewBox: '0 0 24 24', + width: '1em', + height: '1em', fill: 'none', 'aria-hidden': true, }, @@ -549,7 +555,7 @@ function createHeaderActionMenuItemFactory( }; } -export const TableBlockCopyMenuItemFactory = (accessor: IAccessor): IMenuItem => { +export const TableBlockCopyMenuItemFactory = (_accessor: IAccessor): IMenuItem => { return { id: DocCopyCommand.name, commandId: DocCopyCommand.id, @@ -559,7 +565,7 @@ export const TableBlockCopyMenuItemFactory = (accessor: IAccessor): IMenuItem => }; }; -export const TableBlockPasteMenuItemFactory = (accessor: IAccessor): IMenuItem => { +export const TableBlockPasteMenuItemFactory = (_accessor: IAccessor): IMenuItem => { return { id: DocPasteCommand.id, type: MenuItemType.BUTTON, @@ -568,7 +574,7 @@ export const TableBlockPasteMenuItemFactory = (accessor: IAccessor): IMenuItem = }; }; -export const TableBlockDeleteMenuItemFactory = (accessor: IAccessor): IMenuItem => { +export const TableBlockDeleteMenuItemFactory = (_accessor: IAccessor): IMenuItem => { return { id: DocTableDeleteTableCommand.id, type: MenuItemType.BUTTON, @@ -577,7 +583,7 @@ export const TableBlockDeleteMenuItemFactory = (accessor: IAccessor): IMenuItem }; }; -export function DocInsertBellowMenuItemFactory(accessor: IAccessor): IMenuSelectorItem { +export function DocInsertBellowMenuItemFactory(_accessor: IAccessor): IMenuSelectorItem { return { id: INSERT_BELLOW_MENU_ID, type: MenuItemType.SUBITEMS, diff --git a/packages/docs-ui/src/services/__tests__/doc-zoom.spec.ts b/packages/docs-ui/src/services/__tests__/doc-zoom.spec.ts new file mode 100644 index 0000000000..b0e6cad954 --- /dev/null +++ b/packages/docs-ui/src/services/__tests__/doc-zoom.spec.ts @@ -0,0 +1,54 @@ +/** + * Copyright 2023-present DreamNum Co., Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { DocumentFlavor } from '@univerjs/core'; +import { describe, expect, it } from 'vitest'; +import { DEFAULT_MODERN_DOC_ZOOM_RATIO, getDocEffectiveZoomRatio } from '../doc-zoom'; + +describe('doc zoom helpers', () => { + it('defaults modern docs to 120 percent when no explicit zoom is stored', () => { + const documentModel = { + getSettings: () => undefined, + getSnapshot: () => ({ + documentStyle: { + documentFlavor: DocumentFlavor.MODERN, + }, + }), + }; + + expect(getDocEffectiveZoomRatio(documentModel as never)).toBe(DEFAULT_MODERN_DOC_ZOOM_RATIO); + }); + + it('keeps explicit zoom ratios and classic defaults unchanged', () => { + expect(getDocEffectiveZoomRatio({ + getSettings: () => ({ zoomRatio: 1.35 }), + getSnapshot: () => ({ + documentStyle: { + documentFlavor: DocumentFlavor.MODERN, + }, + }), + } as never)).toBe(1.35); + + expect(getDocEffectiveZoomRatio({ + getSettings: () => undefined, + getSnapshot: () => ({ + documentStyle: { + documentFlavor: DocumentFlavor.TRADITIONAL, + }, + }), + } as never)).toBe(1); + }); +}); diff --git a/packages/docs-ui/src/services/doc-page-layout.service.ts b/packages/docs-ui/src/services/doc-page-layout.service.ts index 49f0a537b8..3d44a87aa1 100644 --- a/packages/docs-ui/src/services/doc-page-layout.service.ts +++ b/packages/docs-ui/src/services/doc-page-layout.service.ts @@ -19,6 +19,7 @@ import type { IRenderContext, IRenderModule } from '@univerjs/engine-render'; import { Disposable } from '@univerjs/core'; import { neoGetDocObject } from '../basics/component-tools'; import { VIEWPORT_KEY } from '../basics/docs-view-key'; +import { getDocEffectiveZoomRatio } from './doc-zoom'; export class DocPageLayoutService extends Disposable implements IRenderModule { constructor( @@ -32,7 +33,7 @@ export class DocPageLayoutService extends Disposable implements IRenderModule { const docObject = neoGetDocObject(this._context); const docDataModel = this._context.unit; - const zoomRatio = docDataModel.getSettings()?.zoomRatio ?? 1; + const zoomRatio = getDocEffectiveZoomRatio(docDataModel); const { document: docsComponent, scene, docBackground } = docObject; const parent = scene?.getParent(); diff --git a/packages/docs-ui/src/services/doc-zoom.ts b/packages/docs-ui/src/services/doc-zoom.ts new file mode 100644 index 0000000000..3ee69e3e35 --- /dev/null +++ b/packages/docs-ui/src/services/doc-zoom.ts @@ -0,0 +1,35 @@ +/** + * Copyright 2023-present DreamNum Co., Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { DocumentDataModel } from '@univerjs/core'; +import { DocumentFlavor } from '@univerjs/core'; + +const DEFAULT_DOC_ZOOM_RATIO = 1; +export const DEFAULT_MODERN_DOC_ZOOM_RATIO = 1.2; + +type DocZoomSource = Pick; + +export function getDocEffectiveZoomRatio(documentModel: DocZoomSource): number { + const zoomRatio = documentModel.getSettings()?.zoomRatio; + if (zoomRatio != null) { + return zoomRatio; + } + + const documentFlavor = documentModel.getSnapshot().documentStyle?.documentFlavor; + return documentFlavor === DocumentFlavor.MODERN + ? DEFAULT_MODERN_DOC_ZOOM_RATIO + : DEFAULT_DOC_ZOOM_RATIO; +} diff --git a/packages/docs-ui/src/views/count-bar/ZoomSlider.tsx b/packages/docs-ui/src/views/count-bar/ZoomSlider.tsx index e929f4fa01..b9e5afdcc0 100644 --- a/packages/docs-ui/src/views/count-bar/ZoomSlider.tsx +++ b/packages/docs-ui/src/views/count-bar/ZoomSlider.tsx @@ -19,6 +19,7 @@ import { ICommandService, IUniverInstanceService, UniverInstanceType } from '@un import { Slider, useDependency } from '@univerjs/ui'; import { useCallback, useEffect, useState } from 'react'; import { SetDocZoomRatioOperation } from '../../commands/operations/set-doc-zoom-ratio.operation'; +import { getDocEffectiveZoomRatio } from '../../services/doc-zoom'; const ZOOM_MAP = [50, 80, 100, 130, 150, 170, 200, 400]; const DOC_ZOOM_RANGE = [10, 400]; @@ -29,11 +30,10 @@ export function ZoomSlider() { const [documentDataModel, setDocumentDataModel] = useState(null); const [zoom, setZoom] = useState(100); - const getCurrentZoom = useCallback(() => { - if (!documentDataModel) return 100; + const getCurrentZoom = useCallback((docModel: DocumentDataModel | null = documentDataModel) => { + if (!docModel) return 100; - const currentZoom = ((documentDataModel.getSettings()?.zoomRatio ?? 1) * 100); - return Math.round(currentZoom); + return Math.round(getDocEffectiveZoomRatio(docModel) * 100); }, [documentDataModel]); useEffect(() => { @@ -42,7 +42,7 @@ export function ZoomSlider() { const subscription = currentDoc$.subscribe((doc) => { if (doc) { setDocumentDataModel(doc); - setZoom(getCurrentZoom()); + setZoom(getCurrentZoom(doc)); } }); diff --git a/packages/engine-render/src/components/docs/__tests__/document.spec.ts b/packages/engine-render/src/components/docs/__tests__/document.spec.ts index b747c136d5..635538ca65 100644 --- a/packages/engine-render/src/components/docs/__tests__/document.spec.ts +++ b/packages/engine-render/src/components/docs/__tests__/document.spec.ts @@ -28,7 +28,7 @@ import { DOCS_EXTENSION_TYPE } from '../doc-extension'; import { Documents } from '../document'; import { setDocsTableRenderViewportProvider } from '../table-render-viewport'; -function createGlyph(content: string, left: number, width = 16) { +function createGlyph(content: string, left: number, width = 16, backgroundColor?: string) { return { glyphType: GlyphType.WORD, streamType: 'word', @@ -55,6 +55,11 @@ function createGlyph(content: string, left: number, width = 16) { fs: 12, ff: 'Arial', cl: { rgb: '#222222' }, + ...(backgroundColor + ? { + bg: { rgb: backgroundColor }, + } + : {}), }, fontStyle: { fontString: '12px Arial', @@ -70,9 +75,9 @@ function createGlyph(content: string, left: number, width = 16) { } as any; } -function createLine(type: LineType, top: number, withBorder = false) { - const glyphA = createGlyph('A', 0); - const glyphB = createGlyph('B', 18); +function createLine(type: LineType, top: number, withBorder = false, backgroundColor?: string) { + const glyphA = createGlyph('A', 0, 16, backgroundColor); + const glyphB = createGlyph('B', 18, 16, backgroundColor); const divide = { glyphGroup: [glyphA, glyphB], width: 120, @@ -118,9 +123,9 @@ function createLine(type: LineType, top: number, withBorder = false) { return line; } -function createPage(pageType: DocumentSkeletonPageType, segmentId: string) { - const lineBlock = createLine(LineType.BLOCK, 0); - const lineText = createLine(LineType.PARAGRAPH, 24, true); +function createPage(pageType: DocumentSkeletonPageType, segmentId: string, backgroundColor?: string) { + const lineBlock = createLine(LineType.BLOCK, 0, false, backgroundColor); + const lineText = createLine(LineType.PARAGRAPH, 24, true, backgroundColor); const column = { lines: [lineBlock, lineText], left: 0, @@ -281,7 +286,7 @@ describe('documents render', () => { setDocsTableRenderViewportProvider(null); }); - it('uses explicit table cell border width and skips no-border markers', () => { + it('uses explicit table cell border width inside table render path', () => { const skeleton = { getSkeletonData: () => ({ pages: [] }) } as any; const documents = new Documents('docs-border', skeleton, { pageLayoutType: PageLayoutType.VERTICAL, @@ -946,7 +951,6 @@ describe('documents render', () => { expect(pageEvents.length).toBe(1); expect(clearCache).toHaveBeenCalled(); expect(lineDraw).toHaveBeenCalled(); - expect(bgDraw).toHaveBeenCalled(); expect(spanDraw).toHaveBeenCalled(); documents.draw(canvas.getContext(), { @@ -964,4 +968,52 @@ describe('documents render', () => { documents.dispose(); }); + + it('merges adjacent glyph backgrounds with the same color into one draw per line', () => { + const bodyPage = createPage(DocumentSkeletonPageType.BODY, '', '#d9eaf7'); + const skeletonData = { + pages: [bodyPage], + skeHeaders: new Map(), + skeFooters: new Map(), + }; + bodyPage.parent = skeletonData; + + const skeleton = { + getSkeletonData: () => skeletonData, + } as any; + + const documents = new Documents('docs-merged-background', skeleton, { + pageLayoutType: PageLayoutType.VERTICAL, + pageMarginLeft: 0, + pageMarginTop: 0, + }); + documents.transformByState({ + left: 0, + top: 0, + width: 260, + height: 180, + }); + scene.addObject(documents, 1); + + const bgDraw = vi.fn(); + vi.spyOn(documents as any, 'getExtensionsByOrder').mockReturnValue([ + { + uKey: 'DefaultDocsBackgroundExtension', + type: DOCS_EXTENSION_TYPE.SPAN, + extensionOffset: {}, + clearCache: vi.fn(), + draw: bgDraw, + }, + ] as any); + + documents.draw(canvas.getContext(), { + viewBound: { left: 0, top: 0, right: 900, bottom: 700 }, + cacheBound: { left: 0, top: 0, right: 900, bottom: 700 }, + } as any); + + expect(bgDraw).toHaveBeenCalledTimes(1); + expect(bgDraw.mock.calls.map((call) => call[2].width)).toEqual([34]); + + documents.dispose(); + }); }); diff --git a/packages/engine-render/src/components/docs/document.ts b/packages/engine-render/src/components/docs/document.ts index e860b47154..84537ad1d6 100644 --- a/packages/engine-render/src/components/docs/document.ts +++ b/packages/engine-render/src/components/docs/document.ts @@ -36,6 +36,7 @@ import { Vector2 } from '../../basics/vector2'; import { DocumentsSpanAndLineExtensionRegistry } from '../extension'; import { DocComponent } from './doc-component'; import { DOCS_EXTENSION_TYPE } from './doc-extension'; +import { collectBackgroundGlyphRuns } from './extensions/background-runs'; import { getTableIdAndSliceIndex } from './layout/block/table'; import { Liquid } from './liquid'; import { getDocsTableRenderViewport, hasDocsTableHorizontalViewport } from './table-render-viewport'; @@ -371,37 +372,16 @@ export class Documents extends DocComponent { this._drawLiquid.translateSave(); this._drawLiquid.translateDivide(divide, isVertical && wrapStrategy === WrapStrategy.WRAP, verticalAlign, rotatedHeightStore); - // Draw text background. - for (const glyph of glyphGroup) { - if (!glyph.content || glyph.content.length === 0) { - continue; - } - - const { width: spanWidth, left: spanLeft } = glyph; - - const { x: translateX, y: translateY } = this._drawLiquid; - - const originTranslate = Vector2.create(translateX, translateY); - - const centerPoint = Vector2.create(spanWidth / 2, lineHeight / 2); - - const spanStartPoint = calculateRectRotate( - originTranslate.addByPoint(spanLeft, 0), - centerPoint, - centerAngle, - vertexAngle, - alignOffset - ); - - const extensionOffset: IExtensionConfig = { - spanStartPoint, - }; - - if (backgroundExtension) { - backgroundExtension.extensionOffset = extensionOffset; - backgroundExtension.draw(ctx, parentScale, glyph); - } - } + this._drawGlyphGroupBackgrounds( + ctx, + parentScale, + glyphGroup, + lineHeight, + alignOffset, + centerAngle, + vertexAngle, + backgroundExtension + ); // Draw text\border\lines etc. for (const glyph of glyphGroup) { @@ -668,6 +648,42 @@ export class Documents extends DocComponent { ctx.restore(); } + private _drawGlyphGroupBackgrounds( + ctx: UniverRenderingContext, + parentScale: IScale, + glyphGroup: IDocumentSkeletonGlyph[], + lineHeight: number, + alignOffset: Vector2, + centerAngle: number, + vertexAngle: number, + backgroundExtension: Nullable> + ) { + if (!backgroundExtension || this._drawLiquid == null) { + return; + } + + const backgroundRuns = collectBackgroundGlyphRuns(glyphGroup); + + for (const backgroundRun of backgroundRuns) { + const { glyph, left: spanLeft, width: spanWidth } = backgroundRun; + const { x: translateX, y: translateY } = this._drawLiquid; + const originTranslate = Vector2.create(translateX, translateY); + const centerPoint = Vector2.create(spanWidth / 2, lineHeight / 2); + const spanStartPoint = calculateRectRotate( + originTranslate.addByPoint(spanLeft, 0), + centerPoint, + centerAngle, + vertexAngle, + alignOffset + ); + + backgroundExtension.extensionOffset = { + spanStartPoint, + }; + backgroundExtension.draw(ctx, parentScale, glyph); + } + } + // TODO: @JOCS, DRY!!! private _drawTableCell( ctx: UniverRenderingContext, @@ -747,37 +763,16 @@ export class Documents extends DocComponent { this._drawLiquid.translateSave(); this._drawLiquid.translateDivide(divide); - // Draw text background. - for (const glyph of glyphGroup) { - if (!glyph.content || glyph.content.length === 0) { - continue; - } - - const { width: spanWidth, left: spanLeft } = glyph; - - const { x: translateX, y: translateY } = this._drawLiquid; - - const originTranslate = Vector2.create(translateX, translateY); - - const centerPoint = Vector2.create(spanWidth / 2, lineHeight / 2); - - const spanStartPoint = calculateRectRotate( - originTranslate.addByPoint(spanLeft, 0), - centerPoint, - centerAngle, - vertexAngle, - alignOffset - ); - - const extensionOffset: IExtensionConfig = { - spanStartPoint, - }; - - if (backgroundExtension) { - backgroundExtension.extensionOffset = extensionOffset; - backgroundExtension.draw(ctx, parentScale, glyph); - } - } + this._drawGlyphGroupBackgrounds( + ctx, + parentScale, + glyphGroup, + lineHeight, + alignOffset, + centerAngle, + vertexAngle, + backgroundExtension + ); // Draw text\border\lines etc. for (const glyph of glyphGroup) { @@ -1042,37 +1037,16 @@ export class Documents extends DocComponent { this._drawLiquid.translateSave(); this._drawLiquid.translateDivide(divide); - // Draw text background. - for (const glyph of glyphGroup) { - if (!glyph.content || glyph.content.length === 0) { - continue; - } - - const { width: spanWidth, left: spanLeft } = glyph; - - const { x: translateX, y: translateY } = this._drawLiquid; - - const originTranslate = Vector2.create(translateX, translateY); - - const centerPoint = Vector2.create(spanWidth / 2, lineHeight / 2); - - const spanStartPoint = calculateRectRotate( - originTranslate.addByPoint(spanLeft, 0), - centerPoint, - centerAngle, - vertexAngle, - alignOffset - ); - - const extensionOffset: IExtensionConfig = { - spanStartPoint, - }; - - if (backgroundExtension) { - backgroundExtension.extensionOffset = extensionOffset; - backgroundExtension.draw(ctx, parentScale, glyph); - } - } + this._drawGlyphGroupBackgrounds( + ctx, + parentScale, + glyphGroup, + lineHeight, + alignOffset, + centerAngle, + vertexAngle, + backgroundExtension + ); // Draw text\border\lines etc. for (const glyph of glyphGroup) { @@ -1153,14 +1127,16 @@ export class Documents extends DocComponent { * In Excel, if horizontal alignment is not specified, * rotated text aligns to the right when rotated downwards and aligns to the left when rotated upwards. */ - if (horizontalAlign === HorizontalAlign.UNSPECIFIED) { + let resolvedHorizontalAlign = horizontalAlign; + + if (resolvedHorizontalAlign === HorizontalAlign.UNSPECIFIED) { if (centerAngleDeg === VERTICAL_ROTATE_ANGLE && vertexAngleDeg === VERTICAL_ROTATE_ANGLE) { - horizontalAlign = HorizontalAlign.CENTER; + resolvedHorizontalAlign = HorizontalAlign.CENTER; } else if ((vertexAngleDeg > 0 && vertexAngleDeg !== VERTICAL_ROTATE_ANGLE) || vertexAngleDeg === -VERTICAL_ROTATE_ANGLE) { /** * https://github.com/dream-num/univer-pro/issues/334 */ - horizontalAlign = HorizontalAlign.RIGHT; + resolvedHorizontalAlign = HorizontalAlign.RIGHT; } else { /** * sheet cell type, In a spreadsheet cell, without any alignment settings applied, @@ -1169,19 +1145,19 @@ export class Documents extends DocComponent { * and Boolean values should be center-aligned. */ if (cellValueType === CellValueType.NUMBER) { - horizontalAlign = HorizontalAlign.RIGHT; + resolvedHorizontalAlign = HorizontalAlign.RIGHT; } else if (cellValueType === CellValueType.BOOLEAN) { - horizontalAlign = HorizontalAlign.CENTER; + resolvedHorizontalAlign = HorizontalAlign.CENTER; } else { - horizontalAlign = HorizontalAlign.LEFT; + resolvedHorizontalAlign = HorizontalAlign.LEFT; } } } let offsetLeft = 0; - if (horizontalAlign === HorizontalAlign.CENTER) { + if (resolvedHorizontalAlign === HorizontalAlign.CENTER) { offsetLeft = (this.width - pageWidth) / 2; - } else if (horizontalAlign === HorizontalAlign.RIGHT) { + } else if (resolvedHorizontalAlign === HorizontalAlign.RIGHT) { offsetLeft = this.width - pageWidth - pagePaddingRight; } else { offsetLeft = pagePaddingLeft; diff --git a/packages/engine-render/src/components/docs/extensions/background-runs.ts b/packages/engine-render/src/components/docs/extensions/background-runs.ts new file mode 100644 index 0000000000..b6338e5ccb --- /dev/null +++ b/packages/engine-render/src/components/docs/extensions/background-runs.ts @@ -0,0 +1,84 @@ +/** + * Copyright 2023-present DreamNum Co., Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { IDocumentSkeletonGlyph } from '../../../basics/i-document-skeleton-cached'; +import { getColorStyle } from '@univerjs/core'; + +export interface IBackgroundGlyphRun { + glyph: IDocumentSkeletonGlyph; + left: number; + width: number; +} + +export function collectBackgroundGlyphRuns(glyphGroup: IDocumentSkeletonGlyph[]): IBackgroundGlyphRun[] { + const runs: IBackgroundGlyphRun[] = []; + + let activeGlyph: IDocumentSkeletonGlyph | null = null; + let activeColor = ''; + let activeLeft = 0; + let activeRight = 0; + + const flush = () => { + if (!activeGlyph) { + return; + } + + runs.push({ + glyph: { + ...activeGlyph, + width: activeRight - activeLeft, + }, + left: activeLeft, + width: activeRight - activeLeft, + }); + + activeGlyph = null; + activeColor = ''; + activeLeft = 0; + activeRight = 0; + }; + + for (const glyph of glyphGroup) { + if (!glyph.content || glyph.content === '\r') { + flush(); + continue; + } + + const backgroundColor = glyph.ts?.bg ? getColorStyle(glyph.ts.bg) : ''; + if (!backgroundColor) { + flush(); + continue; + } + + const glyphLeft = glyph.left; + const glyphRight = glyph.left + glyph.width; + + if (activeGlyph && activeColor === backgroundColor) { + activeRight = Math.max(activeRight, glyphRight); + continue; + } + + flush(); + activeGlyph = glyph; + activeColor = backgroundColor; + activeLeft = glyphLeft; + activeRight = glyphRight; + } + + flush(); + + return runs; +} diff --git a/packages/ui/src/components/menu/desktop/DesignTinyMenuGroup.tsx b/packages/ui/src/components/menu/desktop/DesignTinyMenuGroup.tsx index a34113b918..f7bd97aa3e 100644 --- a/packages/ui/src/components/menu/desktop/DesignTinyMenuGroup.tsx +++ b/packages/ui/src/components/menu/desktop/DesignTinyMenuGroup.tsx @@ -91,7 +91,7 @@ export function DesignTinyMenuGroup({ items, columns, sizeVariant = 'default', l dark:!univer-text-gray-200 `, isCompactParagraphVariant - ? 'univer-size-4' + ? 'univer-size-5' : isParagraphTVariant ? 'univer-size-5' : 'univer-size-4', diff --git a/packages/ui/src/components/menu/desktop/__tests__/DesignTinyMenuGroup.spec.tsx b/packages/ui/src/components/menu/desktop/__tests__/DesignTinyMenuGroup.spec.tsx index 4c0d207f2e..4e6b37e1d1 100644 --- a/packages/ui/src/components/menu/desktop/__tests__/DesignTinyMenuGroup.spec.tsx +++ b/packages/ui/src/components/menu/desktop/__tests__/DesignTinyMenuGroup.spec.tsx @@ -44,6 +44,6 @@ describe('DesignTinyMenuGroup', () => { expect(group?.className ?? '').toContain('univer-p-0'); expect(button?.className ?? '').toContain('univer-size-6'); expect(button?.className ?? '').toContain('univer-rounded-sm'); - expect(icon?.className ?? '').toContain('univer-size-4'); + expect(icon?.className ?? '').toContain('univer-size-5'); }); }); diff --git a/packages/ui/src/views/components/context-menu/ContextMenuPanel.tsx b/packages/ui/src/views/components/context-menu/ContextMenuPanel.tsx index 0731aa4b6b..6e828f7989 100644 --- a/packages/ui/src/views/components/context-menu/ContextMenuPanel.tsx +++ b/packages/ui/src/views/components/context-menu/ContextMenuPanel.tsx @@ -870,6 +870,12 @@ function ContextMenuMenuItem(props: IContextMenuMenuItemProps) { onClick={() => { clearSubmenuCloseTimer(); if (hasSubmenu) { + if (headerAction) { + setSubmenuPositionReady(false); + setActiveSubmenuKey(menuKey); + return; + } + if (canExecuteItem) { const item = menuItem as IDisplayMenuItem; onOptionSelect?.({ diff --git a/packages/ui/src/views/components/context-menu/__tests__/ContextMenuPanel.spec.tsx b/packages/ui/src/views/components/context-menu/__tests__/ContextMenuPanel.spec.tsx index 8d39dc4dac..35694a55ca 100644 --- a/packages/ui/src/views/components/context-menu/__tests__/ContextMenuPanel.spec.tsx +++ b/packages/ui/src/views/components/context-menu/__tests__/ContextMenuPanel.spec.tsx @@ -181,7 +181,6 @@ describe('ContextMenuPanel', () => { sizeVariant: 'paragraph-t', })); - const tinyGroups = screen.getAllByTestId('tiny-menu-group'); const sharedCluster = container.querySelector('.univer-gap-0') as HTMLDivElement | null; expect(sharedCluster).not.toBeNull(); @@ -318,6 +317,60 @@ describe('ContextMenuPanel', () => { expect(document.querySelector('button[title="header-action"]')).not.toBeNull(); }); + it('opens a header action selector submenu instead of immediately executing its current value', () => { + dependencyMap.clear(); + tinyMenuGroupSpy.mockClear(); + const onOptionSelect = vi.fn(); + + dependencyMap.set(IMenuManagerService, { + menuChanged$: new BehaviorSubject(undefined), + getMenuByPositionKey: vi.fn(() => [{ + key: 'colors', + order: 0, + title: 'docs-ui.toolbar.textColor.main', + headerActionItem: { + id: 'header-action', + type: MenuItemType.BUTTON_SELECTOR, + icon: 'HeaderTextColorIcon', + tooltip: 'header-action', + selections: [{ + label: 'custom-option', + value: '#ff0000', + }], + }, + children: [{ + key: 'quickItem', + order: 0, + item: { + id: 'quick-item', + type: MenuItemType.BUTTON, + }, + }], + }]), + }); + dependencyMap.set(ILayoutService, { + rootContainerElement: document.body, + }); + dependencyMap.set(LocaleService, { + t: (key: string) => key, + direction$: new BehaviorSubject<'ltr'>('ltr'), + }); + + render(React.createElement(ContextMenuPanel as never, { + menuType: 'quick-layout-menu', + sizeVariant: 'paragraph-t', + onOptionSelect, + })); + + const headerActionButton = document.querySelector('button[title="header-action"]') as HTMLButtonElement | null; + + expect(headerActionButton).not.toBeNull(); + + fireEvent.click(headerActionButton!); + + expect(onOptionSelect).not.toHaveBeenCalled(); + }); + it('renders quick layout group titles before icon rows', () => { dependencyMap.clear();