feat(docs): restore document polish changes (#7055)

This commit is contained in:
Univer
2026-06-10 22:15:09 +08:00
committed by GitHub
parent 7ef01d3415
commit b6d3d97e8a
16 changed files with 491 additions and 206 deletions
@@ -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<string, React.ComponentType<{ className?: string }>>();
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 };
@@ -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<string, unknown>, 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<string, unknown> | undefined, nextRange);
await executeResolvedCommand({
...option,
commandId,
params: commandParams as Record<string, unknown> | 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<string, unknown> | undefined, blockRange);
await executeResolvedCommand({
...option,
commandId,
params: commandParams as Record<string, unknown> | 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<string, unknown> | undefined,
getParagraphMenuCommandTargetRange(commandId, targetRange, formattingRange)
);
params: commandParams as Record<string, unknown> | undefined,
}, getParagraphMenuCommandTargetRange(commandId, targetRange, formattingRange));
layoutService.focus();
handleHideMenu();
};
@@ -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);
});
});
@@ -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<Workbook>(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
);
}
}
+17 -11
View File
@@ -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<string> {
export function DocInsertBellowMenuItemFactory(_accessor: IAccessor): IMenuSelectorItem<string> {
return {
id: INSERT_BELLOW_MENU_ID,
type: MenuItemType.SUBITEMS,
@@ -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);
});
});
@@ -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();
+35
View File
@@ -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<DocumentDataModel, 'getSettings' | 'getSnapshot'>;
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;
}
@@ -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<DocumentDataModel | null>(null);
const [zoom, setZoom] = useState<number>(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));
}
});
@@ -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();
});
});
@@ -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<ComponentExtension<IDocumentSkeletonGlyph | IDocumentSkeletonLine, DOCS_EXTENSION_TYPE, IBoundRectNoAngle[]>>
) {
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;
@@ -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;
}
@@ -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',
@@ -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');
});
});
@@ -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<IMenuButtonItem>;
onOptionSelect?.({
@@ -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<void>(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();