mirror of
https://github.com/dream-num/univer.git
synced 2026-10-16 15:17:56 +08:00
feat(docs): support header/footer fields and preserve editor interactions (#7801)
This commit is contained in:
@@ -653,6 +653,7 @@ function createCommentBody(id: string, text: string): IDocumentBody {
|
||||
const dataStream = `${text}\r\n`;
|
||||
return {
|
||||
dataStream,
|
||||
textRuns: [],
|
||||
paragraphs: [{ startIndex: dataStream.length - 2, paragraphId: `${id}-paragraph` }],
|
||||
sectionBreaks: [{ startIndex: dataStream.length - 1, sectionId: `${id}-section` }],
|
||||
};
|
||||
|
||||
@@ -828,6 +828,7 @@ function createCommentBody(id: string, text: string): IDocumentBody {
|
||||
const dataStream = `${text}\r\n`;
|
||||
return {
|
||||
dataStream,
|
||||
textRuns: [],
|
||||
paragraphs: [{ startIndex: dataStream.length - 2, paragraphId: `${id}-paragraph` }],
|
||||
sectionBreaks: [{ startIndex: dataStream.length - 1, sectionId: `${id}-section` }],
|
||||
};
|
||||
|
||||
@@ -224,6 +224,21 @@ describe('data validation integration', () => {
|
||||
sub.unsubscribe();
|
||||
});
|
||||
|
||||
it('keeps saved resources unchanged after reading rules from an unconfigured sheet', async () => {
|
||||
await commandService.executeCommand(AddDataValidationMutation.id, {
|
||||
unitId: 'unit-1',
|
||||
subUnitId: 'sheet-1',
|
||||
rule: createRule('sheet-1-rule'),
|
||||
});
|
||||
const before = resourceLoaderService.saveUnit('unit-1')?.resources;
|
||||
expect(dataValidationModel.getRules('unit-1', 'sheet-2')).toEqual([]);
|
||||
expect(resourceLoaderService.saveUnit('unit-1')?.resources).toEqual(before);
|
||||
resourceManagerService.unloadResources('unit-1', UniverInstanceType.UNIVER_SHEET);
|
||||
resourceManagerService.loadResources('unit-1', before);
|
||||
expect(dataValidationModel.getRules('unit-1', 'sheet-2')).toEqual([]);
|
||||
expect(resourceLoaderService.saveUnit('unit-1')?.resources).toEqual(before);
|
||||
});
|
||||
|
||||
it('serializes, unloads, and loads rule resources through the resource manager', async () => {
|
||||
await commandService.executeCommand(AddDataValidationMutation.id, {
|
||||
unitId: 'unit-1',
|
||||
|
||||
@@ -15,7 +15,13 @@
|
||||
*/
|
||||
|
||||
import type { ISheetDataValidationRule } from '@univerjs/core';
|
||||
import { Disposable, Inject, IResourceManagerService, IUniverInstanceService, UniverInstanceType } from '@univerjs/core';
|
||||
import {
|
||||
Disposable,
|
||||
Inject,
|
||||
IResourceManagerService,
|
||||
IUniverInstanceService,
|
||||
UniverInstanceType,
|
||||
} from '@univerjs/core';
|
||||
import { DataValidationModel } from '../models/data-validation-model';
|
||||
|
||||
type DataValidationJSON = Record<string, ISheetDataValidationRule[]>;
|
||||
@@ -38,7 +44,9 @@ export class DataValidationResourceController extends Disposable {
|
||||
const resultMap: DataValidationJSON = {};
|
||||
if (map) {
|
||||
map.forEach(([key, v]) => {
|
||||
resultMap[key] = v;
|
||||
if (v.length > 0) {
|
||||
resultMap[key] = v;
|
||||
}
|
||||
});
|
||||
return JSON.stringify(resultMap);
|
||||
}
|
||||
@@ -50,7 +58,7 @@ export class DataValidationResourceController extends Disposable {
|
||||
}
|
||||
try {
|
||||
return JSON.parse(json);
|
||||
} catch (err) {
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -39,7 +39,8 @@ function HexInput({ hsv, onChange }: IInputProps) {
|
||||
const hexValue = useMemo(() => hsvToHex(hsv[0], hsv[1], hsv[2]), [hsv]);
|
||||
|
||||
useEffect(() => {
|
||||
setInputValue(hexValue.replace(/^#/, ''));
|
||||
const value = hexValue.replace(/^#/, '');
|
||||
setInputValue((current) => current.toLowerCase() === value.toLowerCase() ? current : value);
|
||||
}, [hexValue]);
|
||||
|
||||
const isValidHex = (hex: string) => {
|
||||
@@ -49,9 +50,13 @@ function HexInput({ hsv, onChange }: IInputProps) {
|
||||
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = e.target.value.trim();
|
||||
|
||||
if (newValue.length > 6) return;
|
||||
if (newValue.length > 6) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (newValue !== '' && !/^[0-9A-Fa-f]*$/.test(newValue)) return;
|
||||
if (newValue !== '' && !/^[0-9A-Fa-f]*$/.test(newValue)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setInputValue(newValue);
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* 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 { fireEvent, render } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { hexToHsv } from '../color-conversion';
|
||||
import { ColorInput } from '../ColorInput';
|
||||
|
||||
describe('ColorInput', () => {
|
||||
it('preserves typed HEX selection during equivalent color updates and accepts external colors', () => {
|
||||
const onChange = vi.fn();
|
||||
const { container, rerender } = render(
|
||||
<ColorInput hsv={[0, 100, 100]} alpha={1} format="hex" onChange={onChange} />
|
||||
);
|
||||
const input = container.querySelector<HTMLInputElement>('input[maxlength="6"]')!;
|
||||
fireEvent.change(input, { target: { value: 'aB12Cd' } });
|
||||
expect(onChange).toHaveBeenCalledWith(...hexToHsv('aB12Cd'));
|
||||
input.setSelectionRange(1, 3);
|
||||
rerender(<ColorInput hsv={hexToHsv('aB12Cd')} alpha={1} format="hex" onChange={onChange} />);
|
||||
expect(input.value).toBe('aB12Cd');
|
||||
expect([input.selectionStart, input.selectionEnd]).toEqual([1, 3]);
|
||||
rerender(<ColorInput hsv={hexToHsv('00ff00')} alpha={1} format="hex" onChange={onChange} />);
|
||||
expect(input.value.toLowerCase()).toBe('00ff00');
|
||||
});
|
||||
});
|
||||
@@ -363,6 +363,10 @@ export function Dialog(props: IDialogProps) {
|
||||
dir={direction}
|
||||
onClickClose={handleClickClose}
|
||||
onEscapeKeyDown={(e) => {
|
||||
if (e.isComposing) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (keyboard) {
|
||||
handleClickClose();
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { ComponentPropsWithoutRef, ElementRef, HTMLAttributes } from 'react';
|
||||
import type { ComponentPropsWithoutRef, ComponentRef, HTMLAttributes } from 'react';
|
||||
import { Close, Content, Description, Overlay, Portal, Root, Title, Trigger } from '@radix-ui/react-dialog';
|
||||
import { CloseIcon } from '@univerjs/icons';
|
||||
import { forwardRef } from 'react';
|
||||
@@ -30,7 +30,7 @@ const DialogPortal = Portal;
|
||||
const DialogClose = Close;
|
||||
|
||||
const DialogOverlay = forwardRef<
|
||||
ElementRef<typeof Overlay>,
|
||||
ComponentRef<typeof Overlay>,
|
||||
ComponentPropsWithoutRef<typeof Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<Overlay
|
||||
@@ -55,7 +55,7 @@ export interface IDialogContentProps {
|
||||
overlayClassName?: string;
|
||||
}
|
||||
const DialogContent = forwardRef<
|
||||
ElementRef<typeof Content>,
|
||||
ComponentRef<typeof Content>,
|
||||
ComponentPropsWithoutRef<typeof Content> & IDialogContentProps
|
||||
>(({ className, children, closable = true, onClickClose, mountContainer, overlayClassName, ...props }, ref) => (
|
||||
<DialogPortal container={mountContainer ?? undefined}>
|
||||
@@ -92,7 +92,13 @@ const DialogContent = forwardRef<
|
||||
disabled:univer-pointer-events-none
|
||||
rtl:univer-left-4 rtl:univer-right-auto
|
||||
`}
|
||||
onClick={onClickClose}
|
||||
onClick={(event) => {
|
||||
if (onClickClose) {
|
||||
// The custom callback owns dismissal; skip Radix's second close.
|
||||
event.preventDefault();
|
||||
onClickClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CloseIcon className="univer-size-4 univer-text-gray-400" />
|
||||
<span className="univer-sr-only">Close</span>
|
||||
@@ -141,7 +147,7 @@ const DialogFooter = ({
|
||||
DialogFooter.displayName = 'DialogFooter';
|
||||
|
||||
const DialogTitle = forwardRef<
|
||||
ElementRef<typeof Title>,
|
||||
ComponentRef<typeof Title>,
|
||||
ComponentPropsWithoutRef<typeof Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<Title
|
||||
@@ -160,7 +166,7 @@ const DialogTitle = forwardRef<
|
||||
DialogTitle.displayName = Title.displayName;
|
||||
|
||||
const DialogDescription = forwardRef<
|
||||
ElementRef<typeof Description>,
|
||||
ComponentRef<typeof Description>,
|
||||
ComponentPropsWithoutRef<typeof Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<Description
|
||||
|
||||
@@ -113,6 +113,10 @@ export function MobileDialog(props: IMobileDialogProps) {
|
||||
overlayClassName={overlayClassName}
|
||||
dir={direction}
|
||||
onEscapeKeyDown={(event) => {
|
||||
if (event.isComposing) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (keyboard) {
|
||||
close();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* 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 { cleanup, fireEvent, render } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { Dialog } from '../Dialog';
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
describe('Dialog', () => {
|
||||
it('keeps the dialog open for IME candidate Escape and closes after composition ends', () => {
|
||||
const onOpenChange = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
const { getByRole } = render(
|
||||
<Dialog open onOpenChange={onOpenChange} onClose={onClose}>
|
||||
<input aria-label="HEX color" />
|
||||
</Dialog>
|
||||
);
|
||||
const input = getByRole('textbox');
|
||||
input.focus();
|
||||
fireEvent.compositionStart(input);
|
||||
fireEvent.keyDown(input, { key: 'Escape', code: 'Escape', isComposing: true });
|
||||
expect(onOpenChange).not.toHaveBeenCalled();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
expect(document.activeElement).toBe(input);
|
||||
fireEvent.compositionEnd(input);
|
||||
fireEvent.keyDown(input, { key: 'Escape', code: 'Escape' });
|
||||
expect(onOpenChange).toHaveBeenCalledExactlyOnceWith(false);
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([true, false])('keeps Escape disabled without disabling the close control (mask=%s)', (mask) => {
|
||||
const onClose = vi.fn();
|
||||
const { getByRole } = render(
|
||||
<Dialog open mask={mask} keyboard={false} onClose={onClose}>
|
||||
<input aria-label="Dialog draft" />
|
||||
</Dialog>
|
||||
);
|
||||
const input = getByRole('textbox');
|
||||
input.focus();
|
||||
fireEvent.keyDown(input, { key: 'Escape', code: 'Escape', isComposing: true });
|
||||
fireEvent.keyDown(input, { key: 'Escape', code: 'Escape' });
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
fireEvent.click(getByRole('button', { name: 'Close' }));
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -64,4 +64,41 @@ describe('MobileDialog', () => {
|
||||
expect(dialog.style.maxHeight).toBe('80vh');
|
||||
expect(dialog.querySelector<HTMLElement>('.univer-overflow-y-auto')?.style.paddingBottom).toBe('');
|
||||
});
|
||||
|
||||
it('keeps the dialog open for IME candidate Escape and closes after composition ends', () => {
|
||||
const onOpenChange = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
const { getByRole } = render(
|
||||
<MobileDialog open onOpenChange={onOpenChange} onClose={onClose}>
|
||||
<input aria-label="HEX color" />
|
||||
</MobileDialog>
|
||||
);
|
||||
const input = getByRole('textbox');
|
||||
input.focus();
|
||||
fireEvent.compositionStart(input);
|
||||
fireEvent.keyDown(input, { key: 'Escape', code: 'Escape', isComposing: true });
|
||||
expect(onOpenChange).not.toHaveBeenCalled();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
expect(document.activeElement).toBe(input);
|
||||
fireEvent.compositionEnd(input);
|
||||
fireEvent.keyDown(input, { key: 'Escape', code: 'Escape' });
|
||||
expect(onOpenChange).toHaveBeenCalledExactlyOnceWith(false);
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps Escape disabled without disabling the close control', () => {
|
||||
const onClose = vi.fn();
|
||||
const { getByRole } = render(
|
||||
<MobileDialog open keyboard={false} onClose={onClose}>
|
||||
<input aria-label="Dialog draft" />
|
||||
</MobileDialog>
|
||||
);
|
||||
const input = getByRole('textbox');
|
||||
input.focus();
|
||||
fireEvent.keyDown(input, { key: 'Escape', code: 'Escape', isComposing: true });
|
||||
fireEvent.keyDown(input, { key: 'Escape', code: 'Escape' });
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
fireEvent.click(getByRole('button', { name: 'Close' }));
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,7 +15,11 @@
|
||||
*/
|
||||
|
||||
import type { DocumentDataModel, IAccessor, ICommand, IObjectPositionH, IObjectPositionV } from '@univerjs/core';
|
||||
import type { IDocDrawing, IDrawingDocTransform, IUpdateDrawingDocTransformCommandParams } from '@univerjs/docs-drawing';
|
||||
import type {
|
||||
IDocDrawing,
|
||||
IDrawingDocTransform,
|
||||
IUpdateDrawingDocTransformCommandParams,
|
||||
} from '@univerjs/docs-drawing';
|
||||
import type { IDocumentSkeletonCached } from '@univerjs/engine-render';
|
||||
import {
|
||||
CommandType,
|
||||
@@ -138,13 +142,13 @@ function findDrawingAnchor(
|
||||
const bodyAnchor = findDrawingAnchorInPage(page, drawingId, page.marginTop, page.marginLeft);
|
||||
const header = page.headerId == null
|
||||
? undefined
|
||||
: skeletonData.skeHeaders.get(page.headerId)?.get(page.pageWidth);
|
||||
: skeletonData.skeHeaders.get(page.headerId)?.get(page.headerLayoutKey ?? page.pageWidth);
|
||||
const headerAnchor = header == null
|
||||
? null
|
||||
: findDrawingAnchorInPage(header, drawingId, header.marginTop, page.marginLeft);
|
||||
const footer = page.footerId == null
|
||||
? undefined
|
||||
: skeletonData.skeFooters.get(page.footerId)?.get(page.pageWidth);
|
||||
: skeletonData.skeFooters.get(page.footerId)?.get(page.footerLayoutKey ?? page.pageWidth);
|
||||
const footerTop = footer == null ? 0 : page.pageHeight - page.marginBottom + footer.marginTop;
|
||||
const footerAnchor = footer == null
|
||||
? null
|
||||
|
||||
@@ -314,12 +314,12 @@ export class DocDrawingAddRemoveController extends Disposable {
|
||||
for (const page of pages) {
|
||||
const { headerId, footerId, marginTop, marginLeft, marginBottom, pageWidth, pageHeight } = page;
|
||||
if (editArea === DocumentEditArea.HEADER) {
|
||||
const header = skeHeaders.get(headerId)?.get(pageWidth);
|
||||
const header = skeHeaders.get(headerId)?.get(page.headerLayoutKey ?? pageWidth);
|
||||
if (header) {
|
||||
drawingAnchor = findDrawingAnchorInPage(header, drawing.drawingId, header.marginTop, marginLeft);
|
||||
}
|
||||
} else if (editArea === DocumentEditArea.FOOTER) {
|
||||
const footer = skeFooters.get(footerId)?.get(pageWidth);
|
||||
const footer = skeFooters.get(footerId)?.get(page.footerLayoutKey ?? pageWidth);
|
||||
if (footer) {
|
||||
drawingAnchor = findDrawingAnchorInPage(
|
||||
footer,
|
||||
|
||||
+34
-6
@@ -16,7 +16,17 @@
|
||||
|
||||
import type { DocumentDataModel, IDocDrawingBase, IDocDrawingPosition, Nullable } from '@univerjs/core';
|
||||
import type { IDrawingDocTransform, IUpdateDrawingDocTransformCommandParams } from '@univerjs/docs-drawing';
|
||||
import type { BaseObject, Documents, IDocumentSkeletonGlyph, IDocumentSkeletonPage, IDocumentSkeletonRow, IDocumentSkeletonTable, Image, IPoint, Viewport } from '@univerjs/engine-render';
|
||||
import type {
|
||||
BaseObject,
|
||||
Documents,
|
||||
IDocumentSkeletonGlyph,
|
||||
IDocumentSkeletonPage,
|
||||
IDocumentSkeletonRow,
|
||||
IDocumentSkeletonTable,
|
||||
Image,
|
||||
IPoint,
|
||||
Viewport,
|
||||
} from '@univerjs/engine-render';
|
||||
import {
|
||||
BooleanNumber,
|
||||
COLORS,
|
||||
@@ -34,10 +44,28 @@ import {
|
||||
} from '@univerjs/core';
|
||||
import { DocSkeletonManagerService } from '@univerjs/docs';
|
||||
import { findDocDrawing, UpdateDrawingDocTransformCommand } from '@univerjs/docs-drawing';
|
||||
import { DocSelectionRenderService, getAnchorBounding, getOneTextSelectionRange, neoGetDocObject, NodePositionConvertToCursor, TEXT_RANGE_LAYER_INDEX } from '@univerjs/docs-ui';
|
||||
import {
|
||||
DocSelectionRenderService,
|
||||
getAnchorBounding,
|
||||
getOneTextSelectionRange,
|
||||
neoGetDocObject,
|
||||
NodePositionConvertToCursor,
|
||||
TEXT_RANGE_LAYER_INDEX,
|
||||
} from '@univerjs/docs-ui';
|
||||
import { IDrawingManagerService } from '@univerjs/drawing';
|
||||
import { DocumentSkeletonPageType, getColor, IRenderManagerService, Liquid, PageLayoutType, Rect, Vector2 } from '@univerjs/engine-render';
|
||||
import { IMoveInlineDrawingCommand, ITransformNonInlineDrawingCommand } from '../commands/commands/update-doc-drawing.command';
|
||||
import {
|
||||
DocumentSkeletonPageType,
|
||||
getColor,
|
||||
IRenderManagerService,
|
||||
Liquid,
|
||||
PageLayoutType,
|
||||
Rect,
|
||||
Vector2,
|
||||
} from '@univerjs/engine-render';
|
||||
import {
|
||||
IMoveInlineDrawingCommand,
|
||||
ITransformNonInlineDrawingCommand,
|
||||
} from '../commands/commands/update-doc-drawing.command';
|
||||
import {
|
||||
getDocsDrawingBehindText,
|
||||
getDocsDrawingClipPage,
|
||||
@@ -606,7 +634,7 @@ export class DocDrawingTransformerController extends Disposable {
|
||||
|
||||
switch (pageType) {
|
||||
case DocumentSkeletonPageType.HEADER: {
|
||||
const headerSke = skeHeaders.get(headerId)?.get(pageWidth);
|
||||
const headerSke = skeHeaders.get(headerId)?.get(p.headerLayoutKey ?? pageWidth);
|
||||
|
||||
if (headerSke) {
|
||||
this._liquid.translatePagePadding({
|
||||
@@ -621,7 +649,7 @@ export class DocDrawingTransformerController extends Disposable {
|
||||
}
|
||||
|
||||
case DocumentSkeletonPageType.FOOTER: {
|
||||
const footerSke = skeFooters.get(footerId)?.get(pageWidth);
|
||||
const footerSke = skeFooters.get(footerId)?.get(p.footerLayoutKey ?? pageWidth);
|
||||
|
||||
if (footerSke) {
|
||||
this._liquid.translatePagePadding({
|
||||
|
||||
@@ -83,7 +83,6 @@ export class MobileDocDrawingPopupMenuController extends RxDisposable {
|
||||
this._init();
|
||||
}
|
||||
|
||||
// eslint-disable-next-line max-lines-per-function
|
||||
private _init(): void {
|
||||
this.disposeWithMe(
|
||||
this._commandService.onCommandExecuted((command) => {
|
||||
@@ -93,7 +92,6 @@ export class MobileDocDrawingPopupMenuController extends RxDisposable {
|
||||
return edit?.commandId === command.id;
|
||||
});
|
||||
if (command.id === EditDocDrawingOperation.id || opensDrawingEditor) {
|
||||
this._isDrawingPanelOpen = true;
|
||||
this._clearPopups(undefined, true);
|
||||
}
|
||||
if (command.id === SidebarDocDrawingMobileOperation.id) {
|
||||
|
||||
+7
-5
@@ -268,15 +268,17 @@ describe('DocDrawingTransformUpdateController', () => {
|
||||
expect(tracker.shouldRefresh(skeleton, progress(2))).toBe(true);
|
||||
});
|
||||
|
||||
it.each(['column', 'footnote'])('refreshes when a published %s contains drawings', (scope) => {
|
||||
it.each(['column', 'footnote', 'header', 'footer'])('refreshes when a published %s contains drawings', (scope) => {
|
||||
const nestedPage = {
|
||||
skeDrawings: new Map([['column-drawing', {}]]),
|
||||
skeTables: new Map(),
|
||||
skeColumnGroups: new Map(),
|
||||
};
|
||||
const pages = [{
|
||||
headerId: '',
|
||||
footerId: '',
|
||||
headerId: scope === 'header' ? 'header' : '',
|
||||
footerId: scope === 'footer' ? 'footer' : '',
|
||||
headerLayoutKey: 'page-1',
|
||||
footerLayoutKey: 'page-1',
|
||||
pageWidth: 100,
|
||||
skeDrawings: new Map(),
|
||||
skeTables: new Map(),
|
||||
@@ -290,8 +292,8 @@ describe('DocDrawingTransformUpdateController', () => {
|
||||
const skeleton = {
|
||||
getSkeletonData: () => ({
|
||||
pages,
|
||||
skeHeaders: new Map(),
|
||||
skeFooters: new Map(),
|
||||
skeHeaders: new Map(scope === 'header' ? [['header', new Map([['page-1', nestedPage]])]] : []),
|
||||
skeFooters: new Map(scope === 'footer' ? [['footer', new Map([['page-1', nestedPage]])]] : []),
|
||||
}),
|
||||
};
|
||||
const tracker = new DocDrawingPublicationTracker();
|
||||
|
||||
+24
-14
@@ -14,7 +14,14 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { DocumentDataModel, ICommandInfo, IDocDrawingBase, IDrawingParam, IExecutionOptions, ITransformState } from '@univerjs/core';
|
||||
import type {
|
||||
DocumentDataModel,
|
||||
ICommandInfo,
|
||||
IDocDrawingBase,
|
||||
IDrawingParam,
|
||||
IExecutionOptions,
|
||||
ITransformState,
|
||||
} from '@univerjs/core';
|
||||
import type { IRichTextEditingMutationParams } from '@univerjs/docs';
|
||||
import type {
|
||||
DocumentSkeleton,
|
||||
@@ -182,13 +189,16 @@ interface IDocumentDrawingPublicationNestedPage {
|
||||
interface IDocumentDrawingPublicationPage extends IDocumentDrawingPublicationNestedPage {
|
||||
headerId: string;
|
||||
footerId: string;
|
||||
headerLayoutKey?: number | string;
|
||||
footerLayoutKey?: number | string;
|
||||
pageWidth: number;
|
||||
}
|
||||
|
||||
type DocumentDrawingPublicationSkeletonData = Pick<
|
||||
IDocumentSkeletonCached,
|
||||
'skeHeaders' | 'skeFooters'
|
||||
> & { pages: IDocumentDrawingPublicationPage[] };
|
||||
interface IDocumentDrawingPublicationSkeletonData {
|
||||
pages: IDocumentDrawingPublicationPage[];
|
||||
skeHeaders: ReadonlyMap<string, ReadonlyMap<number | string, IDocumentDrawingPublicationNestedPage>>;
|
||||
skeFooters: ReadonlyMap<string, ReadonlyMap<number | string, IDocumentDrawingPublicationNestedPage>>;
|
||||
}
|
||||
|
||||
export class DocDrawingPublicationTracker {
|
||||
private _generation = -1;
|
||||
@@ -203,7 +213,7 @@ export class DocDrawingPublicationTracker {
|
||||
|
||||
shouldRefresh(
|
||||
skeleton: {
|
||||
getSkeletonData: () => DocumentDrawingPublicationSkeletonData | null | undefined | void;
|
||||
getSkeletonData: () => IDocumentDrawingPublicationSkeletonData | null | undefined | void;
|
||||
},
|
||||
progress: DocumentDrawingPublicationProgress
|
||||
): boolean {
|
||||
@@ -471,7 +481,7 @@ function countSkeletonPageDrawings(page: IDocumentDrawingPublicationNestedPage):
|
||||
}
|
||||
|
||||
function countPublishedDrawingOccurrences(
|
||||
skeletonData: DocumentDrawingPublicationSkeletonData,
|
||||
skeletonData: IDocumentDrawingPublicationSkeletonData,
|
||||
publishedPageCount: number
|
||||
): number {
|
||||
let count = 0;
|
||||
@@ -481,14 +491,14 @@ function countPublishedDrawingOccurrences(
|
||||
|
||||
const header = page.headerId == null
|
||||
? undefined
|
||||
: skeletonData.skeHeaders.get(page.headerId)?.get(page.pageWidth);
|
||||
: skeletonData.skeHeaders.get(page.headerId)?.get(page.headerLayoutKey ?? page.pageWidth);
|
||||
if (header != null) {
|
||||
count += countSkeletonPageDrawings(header);
|
||||
}
|
||||
|
||||
const footer = page.footerId == null
|
||||
? undefined
|
||||
: skeletonData.skeFooters.get(page.footerId)?.get(page.pageWidth);
|
||||
: skeletonData.skeFooters.get(page.footerId)?.get(page.footerLayoutKey ?? page.pageWidth);
|
||||
if (footer != null) {
|
||||
count += countSkeletonPageDrawings(footer);
|
||||
}
|
||||
@@ -497,7 +507,7 @@ function countPublishedDrawingOccurrences(
|
||||
}
|
||||
|
||||
function hasNewPublishedPageDrawings(
|
||||
skeletonData: DocumentDrawingPublicationSkeletonData,
|
||||
skeletonData: IDocumentDrawingPublicationSkeletonData,
|
||||
startPageIndex: number,
|
||||
publishedPageCount: number
|
||||
): boolean {
|
||||
@@ -509,14 +519,14 @@ function hasNewPublishedPageDrawings(
|
||||
|
||||
const header = page.headerId == null
|
||||
? undefined
|
||||
: skeletonData.skeHeaders.get(page.headerId)?.get(page.pageWidth);
|
||||
: skeletonData.skeHeaders.get(page.headerId)?.get(page.headerLayoutKey ?? page.pageWidth);
|
||||
if (header != null && hasSkeletonPageDrawings(header)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const footer = page.footerId == null
|
||||
? undefined
|
||||
: skeletonData.skeFooters.get(page.footerId)?.get(page.pageWidth);
|
||||
: skeletonData.skeFooters.get(page.footerId)?.get(page.footerLayoutKey ?? page.pageWidth);
|
||||
if (footer != null && hasSkeletonPageDrawings(footer)) {
|
||||
return true;
|
||||
}
|
||||
@@ -798,7 +808,7 @@ export class DocDrawingTransformUpdateController extends Disposable implements I
|
||||
): void {
|
||||
const selectable = !page.isLayoutPlaceholder && !page.isMaterializationPlaceholder;
|
||||
const { headerId, footerId, pageWidth } = page;
|
||||
const headerPage = headerId ? skeHeaders.get(headerId)?.get(pageWidth) : undefined;
|
||||
const headerPage = headerId ? skeHeaders.get(headerId)?.get(page.headerLayoutKey ?? pageWidth) : undefined;
|
||||
if (headerPage != null) {
|
||||
this._collectSegmentDrawingPositions(
|
||||
unitId,
|
||||
@@ -813,7 +823,7 @@ export class DocDrawingTransformUpdateController extends Disposable implements I
|
||||
);
|
||||
}
|
||||
|
||||
const footerPage = footerId ? skeFooters.get(footerId)?.get(pageWidth) : undefined;
|
||||
const footerPage = footerId ? skeFooters.get(footerId)?.get(page.footerLayoutKey ?? pageWidth) : undefined;
|
||||
if (footerPage != null) {
|
||||
const footerTop = page.pageHeight - footerPage.height - footerPage.marginBottom;
|
||||
this._collectSegmentDrawingPositions(
|
||||
|
||||
@@ -45,7 +45,13 @@ import {
|
||||
OpenImageCropOperation,
|
||||
} from '@univerjs/drawing-ui';
|
||||
import { IRenderManagerService } from '@univerjs/engine-render';
|
||||
import { ContextMenuPosition, FloatingObjectToolbarPosition, IContextMenuService, IMenuManagerService, MenuItemType } from '@univerjs/ui';
|
||||
import {
|
||||
ContextMenuPosition,
|
||||
FloatingObjectToolbarPosition,
|
||||
IContextMenuService,
|
||||
IMenuManagerService,
|
||||
MenuItemType,
|
||||
} from '@univerjs/ui';
|
||||
import { takeUntil } from 'rxjs';
|
||||
import { EditDocDrawingOperation } from '../commands/operations/edit-doc-drawing.operation';
|
||||
import { SidebarDocDrawingOperation } from '../commands/operations/open-drawing-panel.operation';
|
||||
@@ -84,7 +90,6 @@ export class DocDrawingPopupMenuController extends RxDisposable {
|
||||
this._init();
|
||||
}
|
||||
|
||||
// eslint-disable-next-line max-lines-per-function
|
||||
private _init(): void {
|
||||
this.disposeWithMe(
|
||||
this._commandService.onCommandExecuted((command) => {
|
||||
@@ -94,7 +99,6 @@ export class DocDrawingPopupMenuController extends RxDisposable {
|
||||
return edit?.commandId === command.id;
|
||||
});
|
||||
if (command.id === EditDocDrawingOperation.id || opensDrawingEditor) {
|
||||
this._isDrawingPanelOpen = true;
|
||||
this._clearPopups(undefined, true);
|
||||
}
|
||||
if (command.id === SidebarDocDrawingOperation.id) {
|
||||
|
||||
@@ -14,7 +14,14 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { DocumentDataModel, ICommandInfo, IDrawingParam, IObjectPositionH, IObjectPositionV, Nullable } from '@univerjs/core';
|
||||
import type {
|
||||
DocumentDataModel,
|
||||
ICommandInfo,
|
||||
IDrawingParam,
|
||||
IObjectPositionH,
|
||||
IObjectPositionV,
|
||||
Nullable,
|
||||
} from '@univerjs/core';
|
||||
import type { IUpdateDrawingDocTransformCommandParams } from '@univerjs/docs-drawing';
|
||||
import type { IDocumentSkeletonDrawing } from '@univerjs/engine-render';
|
||||
import type { LocaleKey } from '../../locale/types';
|
||||
@@ -181,14 +188,14 @@ export function useDocDrawingPosition(props: IDocDrawingPositionProps) {
|
||||
break;
|
||||
}
|
||||
|
||||
const headerPage = skeHeaders.get(headerId)?.get(pageWidth);
|
||||
const headerPage = skeHeaders.get(headerId)?.get(page.headerLayoutKey ?? pageWidth);
|
||||
if (headerPage?.skeDrawings.has(drawingId)) {
|
||||
drawing = headerPage?.skeDrawings.get(drawingId);
|
||||
pageMarginLeft = marginLeft;
|
||||
break;
|
||||
}
|
||||
|
||||
const footerPage = skeFooters.get(footerId)?.get(pageWidth);
|
||||
const footerPage = skeFooters.get(footerId)?.get(page.footerLayoutKey ?? pageWidth);
|
||||
if (footerPage?.skeDrawings.has(drawingId)) {
|
||||
drawing = footerPage?.skeDrawings.get(drawingId);
|
||||
pageMarginLeft = marginLeft;
|
||||
|
||||
+145
-113
@@ -14,126 +14,158 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { DocumentEditArea } from '@univerjs/engine-render';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { CloseHeaderFooterCommand, CoreHeaderFooterCommand, OpenHeaderFooterPanelCommand } from '../doc-header-footer.command';
|
||||
import type { DocumentDataModel, IDocumentData } from '@univerjs/core';
|
||||
import type { ISuccinctDocRangeParam, RenderUnit } from '@univerjs/engine-render';
|
||||
import {
|
||||
DocumentFlavor,
|
||||
ICommandService,
|
||||
IUniverInstanceService,
|
||||
LocaleService,
|
||||
LocaleType,
|
||||
Univer,
|
||||
UniverInstanceType,
|
||||
} from '@univerjs/core';
|
||||
import {
|
||||
CreateHeaderFooterCommand,
|
||||
DocLayoutExecutorService,
|
||||
DocSelectionManagerService,
|
||||
DocSkeletonManagerService,
|
||||
DocStateChangeManagerService,
|
||||
DocStateEmitService,
|
||||
RichTextEditingMutation,
|
||||
SetTextSelectionsOperation,
|
||||
} from '@univerjs/docs';
|
||||
import {
|
||||
CanvasColorService,
|
||||
DocumentEditArea,
|
||||
ICanvasColorService,
|
||||
IRenderManagerService,
|
||||
RenderManagerService,
|
||||
} from '@univerjs/engine-render';
|
||||
import {
|
||||
DesktopLayoutService,
|
||||
DesktopRibbonService,
|
||||
ILayoutService,
|
||||
IMenuManagerService,
|
||||
IRibbonService,
|
||||
MenuManagerService,
|
||||
} from '@univerjs/ui';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import enUS from '../../../locale/en-US';
|
||||
import { headerFooterRibbonSchema } from '../../../menu/header-footer-ribbon';
|
||||
import { DocSelectionRenderService } from '../../../services/selection/doc-selection-render.service';
|
||||
import { DOC_HEADER_FOOTER_RIBBON_TAB } from '../../../views/header-footer/panel/component-name';
|
||||
import { DocHeaderFooterRibbonOperation } from '../../operations/doc-header-footer-ribbon.operation';
|
||||
import {
|
||||
CloseHeaderFooterCommand,
|
||||
CoreHeaderFooterCommand,
|
||||
OpenHeaderFooterPanelCommand,
|
||||
} from '../doc-header-footer.command';
|
||||
|
||||
describe('CloseHeaderFooterCommand large document regression', () => {
|
||||
it('reuses already-published body pages when leaving header/footer editing', async () => {
|
||||
const calculate = vi.fn();
|
||||
let editArea = DocumentEditArea.HEADER;
|
||||
const skeleton = {
|
||||
getSkeletonData: () => ({ pages: [{}] }),
|
||||
calculate,
|
||||
};
|
||||
const skeletonManager = {
|
||||
getViewModel: () => ({
|
||||
getEditArea: () => editArea,
|
||||
setEditArea: (next: DocumentEditArea) => {
|
||||
editArea = next;
|
||||
},
|
||||
}),
|
||||
getSkeleton: () => skeleton,
|
||||
};
|
||||
const selectionRenderService = {
|
||||
setSegment: vi.fn(),
|
||||
setSegmentPage: vi.fn(),
|
||||
};
|
||||
let renderDependency = 0;
|
||||
const renderObject = {
|
||||
with: vi.fn(() => renderDependency++ === 0 ? skeletonManager : selectionRenderService),
|
||||
scene: {
|
||||
getTransformerByCreate: () => ({ clearSelectedObjects: vi.fn() }),
|
||||
},
|
||||
mainComponent: { makeDirty: vi.fn() },
|
||||
};
|
||||
const commandService = { executeCommand: vi.fn(() => true) };
|
||||
const renderManagerService = { getRenderUnitById: vi.fn(() => renderObject) };
|
||||
const selectionManagerService = { replaceDocRanges: vi.fn() };
|
||||
const instanceService = {
|
||||
getCurrentUnitOfType: vi.fn(() => ({ getUnitId: () => 'doc-1' })),
|
||||
getUnit: vi.fn(() => ({
|
||||
getSnapshot: () => ({ body: { dataStream: 'body\r\n', paragraphs: [{ startIndex: 4 }] } }),
|
||||
})),
|
||||
};
|
||||
const dependencies = [
|
||||
commandService,
|
||||
renderManagerService,
|
||||
selectionManagerService,
|
||||
instanceService,
|
||||
];
|
||||
const accessor = { get: vi.fn(() => dependencies.shift()) };
|
||||
const cleanups: Array<() => void> = [];
|
||||
afterEach(() => cleanups.splice(0).reverse().forEach((dispose) => dispose()));
|
||||
|
||||
const result = await CloseHeaderFooterCommand.handler(accessor as never, { unitId: 'doc-1' });
|
||||
await Promise.resolve();
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(editArea).toBe(DocumentEditArea.BODY);
|
||||
expect(calculate).not.toHaveBeenCalled();
|
||||
expect(renderObject.mainComponent.makeDirty).toHaveBeenCalledWith(true);
|
||||
function createTestBed(existingHeader = true, flavor = DocumentFlavor.TRADITIONAL) {
|
||||
const univer = new Univer();
|
||||
cleanups.push(() => univer.dispose());
|
||||
const injector = univer.__getInjector();
|
||||
injector.add([IRenderManagerService, { useClass: RenderManagerService }]);
|
||||
injector.add([ICanvasColorService, { useClass: CanvasColorService }]);
|
||||
injector.add([ILayoutService, { useClass: DesktopLayoutService }]);
|
||||
injector.add([IMenuManagerService, { useClass: MenuManagerService }]);
|
||||
injector.add([IRibbonService, { useClass: DesktopRibbonService }]);
|
||||
injector.add([DocSelectionManagerService]);
|
||||
injector.add([DocLayoutExecutorService]);
|
||||
injector.add([DocStateEmitService]);
|
||||
injector.add([DocStateChangeManagerService]);
|
||||
injector.get(IMenuManagerService).mergeMenu(headerFooterRibbonSchema);
|
||||
const locale = injector.get(LocaleService);
|
||||
locale.load({ [LocaleType.EN_US]: enUS });
|
||||
locale.setLocale(LocaleType.EN_US);
|
||||
locale.setDirection('ltr');
|
||||
const model = univer.createUnit<IDocumentData, DocumentDataModel>(UniverInstanceType.UNIVER_DOC, {
|
||||
id: 'open-header-doc',
|
||||
body: {
|
||||
dataStream: 'Body\r\n',
|
||||
textRuns: [],
|
||||
paragraphs: [{ paragraphId: 'body', startIndex: 4 }],
|
||||
},
|
||||
headers: existingHeader
|
||||
? { header: { headerId: 'header', body: {
|
||||
dataStream: 'Page \r\n',
|
||||
textRuns: [],
|
||||
paragraphs: [{ paragraphId: 'header-p', startIndex: 5 }],
|
||||
} } }
|
||||
: {},
|
||||
documentStyle: {
|
||||
documentFlavor: flavor,
|
||||
defaultHeaderId: existingHeader ? 'header' : undefined,
|
||||
pageSize: { width: 300, height: 400 },
|
||||
marginTop: 40,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe('OpenHeaderFooterPanelCommand', () => {
|
||||
function createOpenCommandTestBed(defaultHeaderId?: string) {
|
||||
let editArea = DocumentEditArea.BODY;
|
||||
const viewModel = {
|
||||
getDataModel: () => ({
|
||||
getSnapshot: () => ({ id: 'doc-1', documentStyle: { defaultHeaderId } }),
|
||||
}),
|
||||
setEditArea: vi.fn((next: DocumentEditArea) => {
|
||||
editArea = next;
|
||||
}),
|
||||
};
|
||||
const skeletonManager = {
|
||||
getViewModel: () => viewModel,
|
||||
getSkeleton: () => ({
|
||||
getSkeletonData: () => ({ pages: [{ pageNumber: 1, pageNumberStart: 1 }] }),
|
||||
}),
|
||||
};
|
||||
const selectionRenderService = {
|
||||
getSegmentPage: vi.fn(() => 0),
|
||||
setSegment: vi.fn(),
|
||||
setSegmentPage: vi.fn(),
|
||||
};
|
||||
let dependencyIndex = 0;
|
||||
const renderObject = {
|
||||
with: vi.fn(() => dependencyIndex++ === 0 ? skeletonManager : selectionRenderService),
|
||||
};
|
||||
const commandService = { executeCommand: vi.fn() };
|
||||
const instanceService = {
|
||||
getCurrentUnitOfType: vi.fn(() => ({ getUnitId: () => 'doc-1' })),
|
||||
};
|
||||
const renderManagerService = { getRenderUnitById: vi.fn(() => renderObject) };
|
||||
const dependencies = [commandService, instanceService, renderManagerService];
|
||||
const accessor = { get: vi.fn(() => dependencies.shift()) };
|
||||
|
||||
return { accessor, commandService, selectionRenderService, viewModel, getEditArea: () => editArea };
|
||||
injector.get(IUniverInstanceService).focusUnit(model.getUnitId());
|
||||
const render = injector.get(IRenderManagerService).createRender(model.getUnitId()) as RenderUnit;
|
||||
render.deactivate();
|
||||
render.addRenderDependencies([[DocSkeletonManagerService], [DocSelectionRenderService]]);
|
||||
const skeletonManager = render.with(DocSkeletonManagerService);
|
||||
skeletonManager.getSkeleton().calculate();
|
||||
const commands = injector.get(ICommandService);
|
||||
for (const command of [OpenHeaderFooterPanelCommand, CoreHeaderFooterCommand, CreateHeaderFooterCommand, RichTextEditingMutation, SetTextSelectionsOperation, CloseHeaderFooterCommand, DocHeaderFooterRibbonOperation]) {
|
||||
commands.registerCommand(command);
|
||||
}
|
||||
const state = { ranges: [] as ISuccinctDocRangeParam[] };
|
||||
const subscription = injector.get(DocSelectionManagerService).refreshSelection$.subscribe((event) => {
|
||||
if (event) {
|
||||
state.ranges = event.docRanges;
|
||||
}
|
||||
});
|
||||
cleanups.push(() => subscription.unsubscribe());
|
||||
return { model, commands, render, skeletonManager, injector, state };
|
||||
}
|
||||
|
||||
it('rolls back header editing when creating the missing header fails', async () => {
|
||||
const testBed = createOpenCommandTestBed();
|
||||
testBed.commandService.executeCommand.mockResolvedValueOnce(false);
|
||||
|
||||
await expect(OpenHeaderFooterPanelCommand.handler(testBed.accessor as never, {})).resolves.toBe(false);
|
||||
|
||||
expect(testBed.commandService.executeCommand).toHaveBeenCalledWith(
|
||||
CoreHeaderFooterCommand.id,
|
||||
expect.objectContaining({ unitId: 'doc-1' })
|
||||
);
|
||||
expect(testBed.selectionRenderService.setSegment).toHaveBeenLastCalledWith('');
|
||||
expect(testBed.selectionRenderService.setSegmentPage).toHaveBeenLastCalledWith(-1);
|
||||
expect(testBed.getEditArea()).toBe(DocumentEditArea.BODY);
|
||||
describe('header/footer editing commands', () => {
|
||||
it.each([true, false])('opens the contextual ribbon and selects the header (existing=%s)', async (existing) => {
|
||||
const { model, commands, render, skeletonManager, injector, state } = createTestBed(existing);
|
||||
expect(await commands.executeCommand(OpenHeaderFooterPanelCommand.id)).toBe(true);
|
||||
const segmentId = model.getSnapshot().documentStyle.defaultHeaderId;
|
||||
expect(segmentId).toBeTruthy();
|
||||
expect(model.getSelfOrHeaderFooterModel(segmentId)).toBeDefined();
|
||||
expect(render.with(DocSelectionRenderService).getSegment()).toBe(segmentId);
|
||||
expect(render.with(DocSelectionRenderService).getSegmentPage()).toBe(0);
|
||||
expect(skeletonManager.getViewModel().getEditArea()).toBe(DocumentEditArea.HEADER);
|
||||
expect(state.ranges).toEqual([expect.objectContaining({
|
||||
startOffset: 0,
|
||||
endOffset: 0,
|
||||
segmentId,
|
||||
segmentPage: 0,
|
||||
})]);
|
||||
expect((await firstValueFrom(injector.get(IRibbonService).ribbon$)).map((tab) => tab.key))
|
||||
.toContain(DOC_HEADER_FOOTER_RIBBON_TAB);
|
||||
});
|
||||
|
||||
it('selects an existing header before opening the sidebar', async () => {
|
||||
const testBed = createOpenCommandTestBed('existing-header');
|
||||
testBed.commandService.executeCommand.mockResolvedValueOnce(true);
|
||||
it('rolls back header editing when a modern document refuses header creation', async () => {
|
||||
const { commands, render, skeletonManager } = createTestBed(false, DocumentFlavor.MODERN);
|
||||
expect(await commands.executeCommand(OpenHeaderFooterPanelCommand.id)).toBe(false);
|
||||
expect(render.with(DocSelectionRenderService).getSegment()).toBe('');
|
||||
expect(render.with(DocSelectionRenderService).getSegmentPage()).toBe(-1);
|
||||
expect(skeletonManager.getViewModel().getEditArea()).toBe(DocumentEditArea.BODY);
|
||||
});
|
||||
|
||||
await expect(OpenHeaderFooterPanelCommand.handler(testBed.accessor as never, {})).resolves.toBe(true);
|
||||
|
||||
expect(testBed.selectionRenderService.setSegment).toHaveBeenCalledWith('existing-header');
|
||||
expect(testBed.commandService.executeCommand).toHaveBeenCalledTimes(1);
|
||||
expect(testBed.getEditArea()).toBe(DocumentEditArea.HEADER);
|
||||
it('returns to the body without repaginating already-published pages', async () => {
|
||||
const { commands, skeletonManager, injector, state } = createTestBed();
|
||||
expect(await commands.executeCommand(OpenHeaderFooterPanelCommand.id)).toBe(true);
|
||||
const skeleton = skeletonManager.getSkeleton();
|
||||
const pages = skeleton.getSkeletonData()!.pages;
|
||||
const calculate = vi.spyOn(skeleton, 'calculate');
|
||||
expect(await commands.executeCommand(CloseHeaderFooterCommand.id)).toBe(true);
|
||||
expect(skeletonManager.getViewModel().getEditArea()).toBe(DocumentEditArea.BODY);
|
||||
expect(skeleton.getSkeletonData()!.pages).toBe(pages);
|
||||
expect(calculate).not.toHaveBeenCalled();
|
||||
expect((await firstValueFrom(injector.get(IRibbonService).ribbon$)).map((tab) => tab.key))
|
||||
.not
|
||||
.toContain(DOC_HEADER_FOOTER_RIBBON_TAB);
|
||||
expect(state.ranges).toEqual([expect.objectContaining({ startOffset: 0, endOffset: 0 })]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,7 +14,15 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { DocumentDataModel, ICommand, IDocumentBody, IDocumentData, Injector, ITextStyle, Univer } from '@univerjs/core';
|
||||
import type {
|
||||
DocumentDataModel,
|
||||
ICommand,
|
||||
IDocumentBody,
|
||||
IDocumentData,
|
||||
Injector,
|
||||
ITextStyle,
|
||||
Univer,
|
||||
} from '@univerjs/core';
|
||||
import type { ITextRangeWithStyle } from '@univerjs/engine-render';
|
||||
import {
|
||||
BaselineOffset,
|
||||
@@ -234,6 +242,39 @@ describe('Test inline format commands', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each(['', 'header', 'footer'])('formats initially plain text in segment %j and restores styles through undo/redo', async (segmentId) => {
|
||||
const plainBody = (): IDocumentBody => ({
|
||||
dataStream: 'Plain text\r\n',
|
||||
textRuns: [],
|
||||
paragraphs: [{ paragraphId: 'plain', startIndex: 10 }],
|
||||
});
|
||||
const unitId = 'plain-format-doc';
|
||||
const document = univer.createUnit<IDocumentData, DocumentDataModel>(UniverInstanceType.UNIVER_DOC, {
|
||||
id: unitId,
|
||||
body: plainBody(),
|
||||
headers: { header: { headerId: 'header', body: plainBody() } },
|
||||
footers: { footer: { footerId: 'footer', body: plainBody() } },
|
||||
});
|
||||
get(IUniverInstanceService).focusUnit(unitId);
|
||||
const selections = get(DocSelectionManagerService);
|
||||
selections.__TEST_ONLY_setCurrentSelection({ unitId, subUnitId: unitId });
|
||||
selections.__TEST_ONLY_add([{ startOffset: 6, endOffset: 10, collapsed: false, isActive: true, segmentId }]);
|
||||
|
||||
await commandService.executeCommand(SetInlineFormatBoldCommand.id);
|
||||
await commandService.executeCommand(SetInlineFormatFontSizeCommand.id, { value: 20 });
|
||||
const formattedBody = document.getSelfOrHeaderFooterModel(segmentId)?.getBody();
|
||||
expect(formattedBody?.textRuns).toEqual([{ st: 6, ed: 10, ts: { bl: BooleanNumber.TRUE, fs: 20 } }]);
|
||||
for (const other of ['', 'header', 'footer'].filter((id) => id !== segmentId)) {
|
||||
expect(document.getSelfOrHeaderFooterModel(other)?.getBody()?.textRuns).toEqual([]);
|
||||
}
|
||||
const snapshot = Tools.deepClone(document.getSnapshot());
|
||||
await commandService.executeCommand(UndoCommand.id);
|
||||
expect(document.getSelfOrHeaderFooterModel(segmentId)?.getBody()?.textRuns)
|
||||
.toEqual([{ st: 6, ed: 10, ts: { bl: BooleanNumber.TRUE } }]);
|
||||
await commandService.executeCommand(RedoCommand.id);
|
||||
expect(document.getSnapshot()).toEqual(snapshot);
|
||||
});
|
||||
|
||||
it('keeps retained typing style when toggling bold at a collapsed caret without a document mutation', async () => {
|
||||
const selections = get(DocSelectionManagerService);
|
||||
selections.replaceSelectionInfoWithoutRefresh({
|
||||
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* 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 { ICommand, IDocumentData } from '@univerjs/core';
|
||||
import type { RenderUnit } from '@univerjs/engine-render';
|
||||
import {
|
||||
BooleanNumber,
|
||||
CustomRangeType,
|
||||
DataStreamTreeTokenType,
|
||||
DocumentDataModel,
|
||||
DocumentFlavor,
|
||||
ICommandService,
|
||||
IPermissionService,
|
||||
IUniverInstanceService,
|
||||
LocaleService,
|
||||
LocaleType,
|
||||
RedoCommand,
|
||||
UndoCommand,
|
||||
Univer,
|
||||
UniverInstanceType,
|
||||
} from '@univerjs/core';
|
||||
import {
|
||||
DocLayoutExecutorService,
|
||||
DocSelectionManagerService,
|
||||
DocSkeletonManagerService,
|
||||
DocStateChangeManagerService,
|
||||
DocStateEmitService,
|
||||
InsertTextCommand,
|
||||
RichTextEditingMutation,
|
||||
setDocumentPermissionValue,
|
||||
SetTextSelectionsOperation,
|
||||
} from '@univerjs/docs';
|
||||
import {
|
||||
CanvasColorService,
|
||||
ICanvasColorService,
|
||||
IRenderManagerService,
|
||||
RenderManagerService,
|
||||
} from '@univerjs/engine-render';
|
||||
import { UnitAction } from '@univerjs/protocol';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import enUS from '../../../locale/en-US';
|
||||
import { DocMenuStyleService } from '../../../services/doc-menu-style.service';
|
||||
import { InsertHeaderFooterFieldCommand } from '../insert-header-footer-field.command';
|
||||
|
||||
const cleanups: Array<() => void> = [];
|
||||
afterEach(() => {
|
||||
cleanups.splice(0).reverse().forEach((dispose) => dispose());
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
function createTestBed() {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date(2026, 8, 29, 13, 14, 15));
|
||||
const univer = new Univer();
|
||||
cleanups.push(() => univer.dispose());
|
||||
const injector = univer.__getInjector();
|
||||
injector.add([IRenderManagerService, { useClass: RenderManagerService }]);
|
||||
injector.add([ICanvasColorService, { useClass: CanvasColorService }]);
|
||||
injector.add([DocLayoutExecutorService]);
|
||||
injector.add([DocSelectionManagerService]);
|
||||
injector.add([DocStateEmitService]);
|
||||
injector.add([DocStateChangeManagerService]);
|
||||
injector.add([DocMenuStyleService]);
|
||||
const locale = injector.get(LocaleService);
|
||||
locale.load({ [LocaleType.EN_US]: enUS });
|
||||
locale.setLocale(LocaleType.EN_US);
|
||||
locale.setDirection('ltr');
|
||||
const model = univer.createUnit<IDocumentData, DocumentDataModel>(UniverInstanceType.UNIVER_DOC, {
|
||||
id: 'fields-doc',
|
||||
body: { dataStream: 'Body\r\n', paragraphs: [{ paragraphId: 'body-p', startIndex: 4 }] },
|
||||
headers: { header: { headerId: 'header', body: { dataStream: 'Page \r\n', customRanges: [], customDecorations: [], customBlocks: [], paragraphs: [{ paragraphId: 'header-p', startIndex: 5 }], textRuns: [{ st: 0, ed: 5, ts: { bl: BooleanNumber.TRUE } }] } } },
|
||||
footers: { footer: { footerId: 'footer', body: { dataStream: '\r\n', paragraphs: [{ paragraphId: 'footer-p', startIndex: 0 }] } } },
|
||||
documentStyle: { documentFlavor: DocumentFlavor.TRADITIONAL, defaultHeaderId: 'header', defaultFooterId: 'footer', pageNumberStart: 9, pageSize: { width: 300, height: 400 }, marginTop: 40, marginBottom: 40 },
|
||||
});
|
||||
injector.get(IUniverInstanceService).focusUnit(model.getUnitId());
|
||||
const render = injector.get(IRenderManagerService).createRender(model.getUnitId()) as RenderUnit;
|
||||
render.deactivate();
|
||||
render.addRenderDependencies([[DocSkeletonManagerService]]);
|
||||
render.with(DocSkeletonManagerService).getSkeleton().calculate();
|
||||
const commands = injector.get(ICommandService);
|
||||
for (const command of [InsertHeaderFooterFieldCommand, InsertTextCommand, RichTextEditingMutation, SetTextSelectionsOperation]) {
|
||||
commands.registerCommand(command as ICommand);
|
||||
}
|
||||
injector.get(DocStateChangeManagerService);
|
||||
const selections = injector.get(DocSelectionManagerService);
|
||||
selections.__TEST_ONLY_setCurrentSelection({ unitId: model.getUnitId(), subUnitId: model.getUnitId() });
|
||||
selections.__TEST_ONLY_add([{ startOffset: 5, endOffset: 5, collapsed: true, isActive: true, segmentId: 'header', segmentPage: 0 }]);
|
||||
return { model, commands, injector };
|
||||
}
|
||||
|
||||
describe('InsertHeaderFooterFieldCommand', () => {
|
||||
it('inserts a PAGE field into mixed text with style, snapshot round-trip and undo/redo', async () => {
|
||||
const { model, commands, injector } = createTestBed();
|
||||
const before = JSON.parse(JSON.stringify(model.getSnapshot()));
|
||||
expect(await commands.executeCommand(InsertHeaderFooterFieldCommand.id, { unitId: model.getUnitId(), fieldType: 'PAGE' })).toBe(true);
|
||||
injector.get(DocStateChangeManagerService).flushPendingChanges(model.getUnitId());
|
||||
const after = JSON.parse(JSON.stringify(model.getSnapshot()));
|
||||
const body = model.getSelfOrHeaderFooterModel('header')!.getBody()!;
|
||||
expect(body.dataStream).toBe(`Page ${DataStreamTreeTokenType.CUSTOM_RANGE_START}9${DataStreamTreeTokenType.CUSTOM_RANGE_END}\r\n`);
|
||||
expect(body.customRanges).toEqual([expect.objectContaining({ rangeType: CustomRangeType.FIELD, startIndex: 5, endIndex: 7, properties: { fieldType: 'PAGE', instruction: 'PAGE', cachedResult: '9', sourceKind: 'complex' } })]);
|
||||
expect(body.textRuns?.some((run) => run.st <= 6 && run.ed > 6 && run.ts?.bl === BooleanNumber.TRUE)).toBe(true);
|
||||
const restored = new DocumentDataModel(after);
|
||||
expect(restored.getSelfOrHeaderFooterModel('header')!.getBody()).toEqual(body);
|
||||
restored.dispose();
|
||||
expect(await commands.executeCommand(UndoCommand.id)).toBe(true);
|
||||
expect(model.getSnapshot()).toEqual(before);
|
||||
expect(await commands.executeCommand(RedoCommand.id)).toBe(true);
|
||||
expect(model.getSnapshot()).toEqual(after);
|
||||
});
|
||||
|
||||
it('uses the captured footer selection, supports fixed text and refuses stale or read-only insertion', async () => {
|
||||
const { model, commands, injector } = createTestBed();
|
||||
const target = { unitId: model.getUnitId(), segmentId: 'footer', selection: { startOffset: 0, endOffset: 0, collapsed: true }, fieldType: 'DATE', format: 'short', automatic: false, expectedDataStream: '\r\n' };
|
||||
expect(await commands.executeCommand(InsertHeaderFooterFieldCommand.id, target)).toBe(true);
|
||||
expect(model.getSelfOrHeaderFooterModel('footer')!.getBody()?.dataStream).toBe('09/29/2026\r\n');
|
||||
expect(model.getSelfOrHeaderFooterModel('footer')!.getBody()?.customRanges ?? []).toEqual([]);
|
||||
expect(await commands.executeCommand(InsertHeaderFooterFieldCommand.id, target)).toBe(false);
|
||||
setDocumentPermissionValue(injector.get(IPermissionService), model.getUnitId(), model.getUnitId(), UnitAction.Edit, false);
|
||||
expect(await commands.executeCommand(InsertHeaderFooterFieldCommand.id, { unitId: model.getUnitId(), fieldType: 'TIME' })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -90,7 +90,6 @@ import {
|
||||
import {
|
||||
CloseHeaderFooterCommand,
|
||||
CoreHeaderFooterCommand,
|
||||
OpenHeaderFooterPanelCommand,
|
||||
} from '../doc-header-footer.command';
|
||||
import { HorizontalLineCommand, InsertHorizontalLineBellowCommand } from '../doc-horizontal-line.command';
|
||||
import { DocPageSetupCommand } from '../doc-page-setup.command';
|
||||
@@ -2081,66 +2080,21 @@ describe('misc document commands', () => {
|
||||
expect(await commandService.executeCommand(CloseHeaderFooterCommand.id)).toBe(false);
|
||||
});
|
||||
|
||||
it('opens and closes the header footer sidebar panel through commands and operations', async () => {
|
||||
it('opens and closes the legacy header footer sidebar through its operation', async () => {
|
||||
({ univer, get } = createCommandTestBed(createHeaderFooterDoc(), [
|
||||
[ISidebarService, { useClass: TestSidebarService }],
|
||||
]));
|
||||
loadTestLocale();
|
||||
commandService = get(ICommandService);
|
||||
commandService.registerCommand(OpenHeaderFooterPanelCommand);
|
||||
commandService.registerCommand(CoreHeaderFooterCommand);
|
||||
commandService.registerCommand(CreateHeaderFooterCommand);
|
||||
commandService.registerCommand(RichTextEditingMutation as unknown as ICommand);
|
||||
commandService.registerCommand(SidebarDocHeaderFooterPanelOperation);
|
||||
|
||||
const sidebarService = get(ISidebarService) as TestSidebarService;
|
||||
const render = get(IRenderManagerService).getRenderUnitById('test-doc')!;
|
||||
const mutableRender = render as unknown as {
|
||||
with: (dependency: unknown) => unknown;
|
||||
};
|
||||
const skeletonManager = get(DocSkeletonManagerService) as unknown as {
|
||||
getSkeleton: () => unknown;
|
||||
getViewModel: () => { getEditArea: () => DocumentEditArea; setEditArea: (area: DocumentEditArea) => void };
|
||||
};
|
||||
skeletonManager.getSkeleton = () => ({
|
||||
getSkeletonData: () => ({
|
||||
pages: [{ pageNumber: 1, pageNumberStart: 1 }],
|
||||
}),
|
||||
});
|
||||
let segment = '';
|
||||
let segmentPage = -1;
|
||||
const selectionRenderService = {
|
||||
getSegment: () => segment,
|
||||
setSegment: (next: string) => {
|
||||
segment = next;
|
||||
},
|
||||
getSegmentPage: () => segmentPage,
|
||||
setSegmentPage: (next: number) => {
|
||||
segmentPage = next;
|
||||
},
|
||||
};
|
||||
const originalWith = mutableRender.with.bind(mutableRender);
|
||||
mutableRender.with = (dependency: unknown) => {
|
||||
if (dependency === DocSkeletonManagerService) {
|
||||
return skeletonManager;
|
||||
}
|
||||
if (dependency === DocSelectionRenderService) {
|
||||
return selectionRenderService;
|
||||
}
|
||||
return originalWith(dependency);
|
||||
};
|
||||
|
||||
expect(await commandService.executeCommand(OpenHeaderFooterPanelCommand.id)).toBe(true);
|
||||
expect(await commandService.executeCommand(SidebarDocHeaderFooterPanelOperation.id, { value: 'open' })).toBe(true);
|
||||
expect(sidebarService.visible).toBe(true);
|
||||
expect(skeletonManager.getViewModel().getEditArea()).toBe(DocumentEditArea.HEADER);
|
||||
expect(segmentPage).toBe(0);
|
||||
expect(segment).not.toBe('');
|
||||
expect(getDoc()?.getSnapshot().documentStyle.defaultHeaderId).toBe(segment);
|
||||
expect(sidebarService.options).toEqual(expect.objectContaining({
|
||||
width: 400,
|
||||
children: { label: COMPONENT_DOC_HEADER_FOOTER_PANEL },
|
||||
}));
|
||||
|
||||
expect(await commandService.executeCommand(SidebarDocHeaderFooterPanelOperation.id, { value: 'close' })).toBe(true);
|
||||
expect(sidebarService.visible).toBe(false);
|
||||
});
|
||||
|
||||
+259
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* 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, ICommand, IDocumentData } from '@univerjs/core';
|
||||
import type { DateTimeFieldFormat } from '../../../utils/date-time-field';
|
||||
import {
|
||||
BooleanNumber,
|
||||
CustomRangeType,
|
||||
DataStreamTreeTokenType,
|
||||
FOCUSING_DOC,
|
||||
FOCUSING_UNIVER_EDITOR,
|
||||
ICommandService,
|
||||
IContextService,
|
||||
IPermissionService,
|
||||
IUniverInstanceService,
|
||||
LocaleService,
|
||||
LocaleType,
|
||||
RedoCommand,
|
||||
UndoCommand,
|
||||
Univer,
|
||||
UniverInstanceType,
|
||||
} from '@univerjs/core';
|
||||
import {
|
||||
DocSelectionManagerService,
|
||||
DocStateChangeManagerService,
|
||||
DocStateEmitService,
|
||||
InsertTextCommand,
|
||||
RichTextEditingMutation,
|
||||
setDocumentPermissionValue,
|
||||
SetTextSelectionsOperation,
|
||||
} from '@univerjs/docs';
|
||||
import { IRenderManagerService, RenderManagerService } from '@univerjs/engine-render';
|
||||
import { UnitAction } from '@univerjs/protocol';
|
||||
import {
|
||||
IPlatformService,
|
||||
IUIRuntimeScopeService,
|
||||
KeyCode,
|
||||
PlatformService,
|
||||
ShortcutService,
|
||||
UIRuntimeScopeService,
|
||||
} from '@univerjs/ui';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import enUS from '../../../locale/en-US';
|
||||
import { UpdateDocFieldsShortcut } from '../../../shortcuts/format.shortcut';
|
||||
import { createDateTimeField } from '../../../utils/date-time-field';
|
||||
import {
|
||||
RefreshHeaderFooterFieldsCommand,
|
||||
UpdateSelectedDocFieldsCommand,
|
||||
} from '../refresh-header-footer-fields.command';
|
||||
|
||||
const cleanups: Array<() => void> = [];
|
||||
afterEach(() => {
|
||||
cleanups.splice(0).reverse().forEach((dispose) => dispose());
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
function createTestBed(locked = false, multipleStories = false, format: DateTimeFieldFormat = 'long') {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date(2026, 8, 29, 13, 14, 15));
|
||||
const univer = new Univer();
|
||||
cleanups.push(() => univer.dispose());
|
||||
const injector = univer.__getInjector();
|
||||
injector.add([IRenderManagerService, { useClass: RenderManagerService }]);
|
||||
injector.add([DocSelectionManagerService]);
|
||||
injector.add([DocStateEmitService]);
|
||||
injector.add([DocStateChangeManagerService]);
|
||||
const locale = injector.get(LocaleService);
|
||||
locale.load({ [LocaleType.EN_US]: enUS });
|
||||
locale.setLocale(LocaleType.EN_US);
|
||||
locale.setDirection('ltr');
|
||||
const properties = createDateTimeField(format, 'en-US', new Date(2026, 4, 1));
|
||||
const text = `${DataStreamTreeTokenType.CUSTOM_RANGE_START}${properties.cachedResult}${DataStreamTreeTokenType.CUSTOM_RANGE_END}`;
|
||||
const snapshot: IDocumentData = {
|
||||
id: 'refresh-doc',
|
||||
body: { dataStream: 'Body\r\n' },
|
||||
documentStyle: {},
|
||||
headers: { header: { headerId: 'header', body: {
|
||||
dataStream: `${text} fixed\r\n`,
|
||||
customRanges: [{ rangeId: 'date-field', startIndex: 0, endIndex: text.length - 1, rangeType: CustomRangeType.FIELD, properties: { ...properties, locked: locked ? BooleanNumber.TRUE : BooleanNumber.FALSE } }],
|
||||
textRuns: [{ st: 0, ed: text.length, ts: { lang: 'en-US', bl: BooleanNumber.TRUE } }],
|
||||
paragraphs: [{ paragraphId: 'date-paragraph', startIndex: text.length + 6 }],
|
||||
customDecorations: [],
|
||||
customBlocks: [],
|
||||
} } },
|
||||
};
|
||||
if (multipleStories) {
|
||||
snapshot.body = JSON.parse(JSON.stringify(snapshot.headers!.header.body));
|
||||
snapshot.footers = { footer: { footerId: 'footer', body: JSON.parse(JSON.stringify(snapshot.body)) } };
|
||||
}
|
||||
const model = univer.createUnit<IDocumentData, DocumentDataModel>(UniverInstanceType.UNIVER_DOC, snapshot);
|
||||
injector.get(IUniverInstanceService).focusUnit(model.getUnitId());
|
||||
const commands = injector.get(ICommandService);
|
||||
for (const command of [RefreshHeaderFooterFieldsCommand, UpdateSelectedDocFieldsCommand, InsertTextCommand, RichTextEditingMutation, SetTextSelectionsOperation]) {
|
||||
commands.registerCommand(command as ICommand);
|
||||
}
|
||||
injector.get(DocStateChangeManagerService);
|
||||
return { commands, model, injector };
|
||||
}
|
||||
|
||||
describe('RefreshHeaderFooterFieldsCommand', () => {
|
||||
it('updates the cached value and offsets together, preserves text and supports undo/redo', async () => {
|
||||
const { commands, model, injector } = createTestBed();
|
||||
const before = JSON.parse(JSON.stringify(model.getSnapshot()));
|
||||
expect(await commands.executeCommand(RefreshHeaderFooterFieldsCommand.id, { unitId: model.getUnitId() })).toBe(true);
|
||||
injector.get(DocStateChangeManagerService).flushPendingChanges(model.getUnitId());
|
||||
const body = model.getSelfOrHeaderFooterModel('header')!.getBody()!;
|
||||
expect(body.dataStream).toBe(`${DataStreamTreeTokenType.CUSTOM_RANGE_START}September 29, 2026${DataStreamTreeTokenType.CUSTOM_RANGE_END} fixed\r\n`);
|
||||
expect(body.customRanges?.[0]).toMatchObject({ startIndex: 0, endIndex: 19, properties: { cachedResult: 'September 29, 2026' } });
|
||||
expect(body.paragraphs?.[0].startIndex).toBe(body.dataStream.length - 2);
|
||||
const after = JSON.parse(JSON.stringify(model.getSnapshot()));
|
||||
expect(await commands.executeCommand(UndoCommand.id)).toBe(true);
|
||||
expect(model.getSnapshot()).toEqual(before);
|
||||
expect(await commands.executeCommand(RedoCommand.id)).toBe(true);
|
||||
expect(model.getSnapshot()).toEqual(after);
|
||||
});
|
||||
|
||||
it.each([true, false])('preserves locked or read-only fields (locked=%s)', async (locked) => {
|
||||
const { commands, model, injector } = createTestBed(locked);
|
||||
if (!locked) {
|
||||
setDocumentPermissionValue(injector.get(IPermissionService), model.getUnitId(), model.getUnitId(), UnitAction.Edit, false);
|
||||
}
|
||||
const before = JSON.parse(JSON.stringify(model.getSnapshot()));
|
||||
expect(await commands.executeCommand(RefreshHeaderFooterFieldsCommand.id, { unitId: model.getUnitId() })).toBe(true);
|
||||
expect(model.getSnapshot()).toEqual(before);
|
||||
});
|
||||
|
||||
it.each(['', 'header', 'footer'])('updates only the selected story after editing a date result (%s)', async (segmentId) => {
|
||||
const { commands, model, injector } = createTestBed(false, true);
|
||||
const selections = injector.get(DocSelectionManagerService);
|
||||
selections.__TEST_ONLY_setCurrentSelection({ unitId: model.getUnitId(), subUnitId: model.getUnitId() });
|
||||
selections.__TEST_ONLY_add([{ startOffset: 2, endOffset: 2, collapsed: true, isActive: true, segmentId }]);
|
||||
const body = model.getSelfOrHeaderFooterModel(segmentId)!.getBody()!;
|
||||
const originalEnd = body.customRanges![0].endIndex;
|
||||
expect(await commands.executeCommand(InsertTextCommand.id, {
|
||||
unitId: model.getUnitId(),
|
||||
segmentId,
|
||||
debounce: false,
|
||||
range: { startOffset: 1, endOffset: originalEnd, collapsed: false },
|
||||
body: { dataStream: 'edited' },
|
||||
textRanges: [{ startOffset: 2, endOffset: 2, collapsed: true, isActive: true, segmentId }],
|
||||
})).toBe(true);
|
||||
injector.get(DocStateChangeManagerService).flushPendingChanges(model.getUnitId());
|
||||
expect(body.dataStream).toContain('edited');
|
||||
expect(body.customRanges![0].properties?.fieldType).toBe('DATE');
|
||||
const before = JSON.parse(JSON.stringify(model.getSnapshot()));
|
||||
expect(await commands.executeCommand(UpdateSelectedDocFieldsCommand.id)).toBe(true);
|
||||
injector.get(DocStateChangeManagerService).flushPendingChanges(model.getUnitId());
|
||||
expect(body.dataStream).toContain('September 29, 2026');
|
||||
for (const [other, original] of Object.entries({ '': before.body, header: before.headers.header.body, footer: before.footers.footer.body }).filter(([id]) => id !== segmentId)) {
|
||||
expect(model.getSelfOrHeaderFooterModel(other)!.getBody()).toEqual(original);
|
||||
}
|
||||
expect(await commands.executeCommand(UndoCommand.id)).toBe(true);
|
||||
expect(model.getSnapshot()).toEqual(before);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['', 'long'],
|
||||
['header', 'long'],
|
||||
['footer', 'long'],
|
||||
['', 'time'],
|
||||
['header', 'time'],
|
||||
['footer', 'time'],
|
||||
] as const)('routes F9 through the keyboard service to refresh the edited %s %s field only when focused', async (segmentId, format) => {
|
||||
const { commands, model, injector } = createTestBed(false, true, format);
|
||||
injector.add([IPlatformService, { useClass: PlatformService }]);
|
||||
injector.add([IUIRuntimeScopeService, { useClass: UIRuntimeScopeService }]);
|
||||
injector.add([ShortcutService]);
|
||||
const shortcuts = injector.get(ShortcutService);
|
||||
cleanups.push(() => shortcuts.dispose());
|
||||
const registration = shortcuts.registerShortcut(UpdateDocFieldsShortcut);
|
||||
cleanups.push(() => registration.dispose());
|
||||
const selections = injector.get(DocSelectionManagerService);
|
||||
selections.__TEST_ONLY_setCurrentSelection({ unitId: model.getUnitId(), subUnitId: model.getUnitId() });
|
||||
selections.__TEST_ONLY_add([{ startOffset: 2, endOffset: 2, collapsed: true, isActive: true, segmentId }]);
|
||||
await commands.executeCommand(InsertTextCommand.id, {
|
||||
unitId: model.getUnitId(),
|
||||
segmentId,
|
||||
debounce: true,
|
||||
range: { startOffset: 2, endOffset: 2, collapsed: true },
|
||||
body: { dataStream: 'X' },
|
||||
textRanges: [{ startOffset: 3, endOffset: 3, collapsed: true, isActive: true, segmentId }],
|
||||
});
|
||||
const before = JSON.parse(JSON.stringify(model.getSnapshot()));
|
||||
const context = injector.get(IContextService);
|
||||
context.setContextValue(FOCUSING_DOC, true);
|
||||
context.setContextValue(FOCUSING_UNIVER_EDITOR, false);
|
||||
const unfocused = new KeyboardEvent('keydown', { key: 'F9', keyCode: KeyCode.F9, bubbles: true, cancelable: true });
|
||||
window.dispatchEvent(unfocused);
|
||||
expect(unfocused.defaultPrevented).toBe(false);
|
||||
expect(model.getSnapshot()).toEqual(before);
|
||||
context.setContextValue(FOCUSING_UNIVER_EDITOR, true);
|
||||
const complete = new Promise<void>((resolve) => {
|
||||
const listener = commands.onCommandExecuted((info) => {
|
||||
if (info.id === UpdateSelectedDocFieldsCommand.id) {
|
||||
listener.dispose();
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
cleanups.push(() => listener.dispose());
|
||||
});
|
||||
const focused = new KeyboardEvent('keydown', { key: 'F9', keyCode: KeyCode.F9, bubbles: true, cancelable: true });
|
||||
window.dispatchEvent(focused);
|
||||
await complete;
|
||||
expect(focused.defaultPrevented).toBe(true);
|
||||
const body = model.getSelfOrHeaderFooterModel(segmentId)!.getBody()!;
|
||||
expect(body.dataStream).toContain(format === 'time' ? '13:14:15' : 'September 29, 2026');
|
||||
for (const [other, original] of Object.entries({ '': before.body, header: before.headers.header.body, footer: before.footers.footer.body }).filter(([id]) => id !== segmentId)) {
|
||||
expect(model.getSelfOrHeaderFooterModel(other)!.getBody()).toEqual(original);
|
||||
}
|
||||
const after = JSON.parse(JSON.stringify(model.getSnapshot()));
|
||||
await commands.executeCommand(UndoCommand.id);
|
||||
expect(model.getSnapshot()).toEqual(before);
|
||||
await commands.executeCommand(RedoCommand.id);
|
||||
expect(model.getSnapshot()).toEqual(after);
|
||||
});
|
||||
|
||||
it.each(['', 'FIXED'])('does not recreate a removed field after replacing it with %j', async (replacement) => {
|
||||
const { commands, model, injector } = createTestBed();
|
||||
const selections = injector.get(DocSelectionManagerService);
|
||||
selections.__TEST_ONLY_setCurrentSelection({ unitId: model.getUnitId(), subUnitId: model.getUnitId() });
|
||||
const body = model.getSelfOrHeaderFooterModel('header')!.getBody()!;
|
||||
await commands.executeCommand(InsertTextCommand.id, {
|
||||
unitId: model.getUnitId(),
|
||||
segmentId: 'header',
|
||||
debounce: false,
|
||||
range: { startOffset: 0, endOffset: body.customRanges![0].endIndex + 1, collapsed: false },
|
||||
body: { dataStream: replacement },
|
||||
textRanges: [{ startOffset: replacement.length, endOffset: replacement.length, collapsed: true, segmentId: 'header' }],
|
||||
});
|
||||
expect(body.customRanges).toEqual([]);
|
||||
expect(body.dataStream).toBe(`${replacement} fixed\r\n`);
|
||||
const before = JSON.parse(JSON.stringify(model.getSnapshot()));
|
||||
await commands.executeCommand(UpdateSelectedDocFieldsCommand.id);
|
||||
expect(model.getSnapshot()).toEqual(before);
|
||||
});
|
||||
|
||||
it('does not update any field with a caret outside the field', async () => {
|
||||
const { commands, model, injector } = createTestBed();
|
||||
const selections = injector.get(DocSelectionManagerService);
|
||||
selections.__TEST_ONLY_setCurrentSelection({ unitId: model.getUnitId(), subUnitId: model.getUnitId() });
|
||||
selections.__TEST_ONLY_add([{ startOffset: 0, endOffset: 0, collapsed: true, isActive: true, segmentId: 'header' }]);
|
||||
const before = JSON.parse(JSON.stringify(model.getSnapshot()));
|
||||
expect(await commands.executeCommand(UpdateSelectedDocFieldsCommand.id)).toBe(true);
|
||||
expect(model.getSnapshot()).toEqual(before);
|
||||
});
|
||||
});
|
||||
@@ -25,7 +25,13 @@ import {
|
||||
resolveSectionHeaderFooterReferences,
|
||||
UniverInstanceType,
|
||||
} from '@univerjs/core';
|
||||
import { CreateHeaderFooterCommand, DocSelectionManagerService, DocSkeletonManagerService, getTopLevelSectionBreaks, HeaderFooterType } from '@univerjs/docs';
|
||||
import {
|
||||
CreateHeaderFooterCommand,
|
||||
DocSelectionManagerService,
|
||||
DocSkeletonManagerService,
|
||||
getTopLevelSectionBreaks,
|
||||
HeaderFooterType,
|
||||
} from '@univerjs/docs';
|
||||
import { DocumentEditArea, IRenderManagerService } from '@univerjs/engine-render';
|
||||
import { findFirstCursorOffset } from '../../basics/selection';
|
||||
import { DocSelectionRenderService } from '../../services/selection/doc-selection-render.service';
|
||||
@@ -84,9 +90,9 @@ export const CoreHeaderFooterCommand: ICommand<ICoreHeaderFooterParams> = {
|
||||
const value = headerFooterProps[key as keyof IHeaderFooterProps];
|
||||
|
||||
// need create first page header/footer if useFirstPageHeaderFooter is true and firstPageHeaderId is not set.
|
||||
if (resolvedCreateType == null && key === 'useFirstPageHeaderFooter' && value === BooleanNumber.TRUE && !headerFooterConfig.firstPageHeaderId) {
|
||||
if (resolvedCreateType == null && key === 'useFirstPageHeaderFooter' && value === BooleanNumber.TRUE && !(editArea === DocumentEditArea.HEADER ? headerFooterConfig.firstPageHeaderId : headerFooterConfig.firstPageFooterId)) {
|
||||
resolvedCreateType = editArea === DocumentEditArea.HEADER ? HeaderFooterType.FIRST_PAGE_HEADER : HeaderFooterType.FIRST_PAGE_FOOTER;
|
||||
} else if (resolvedCreateType == null && key === 'evenAndOddHeaders' && value === BooleanNumber.TRUE && !headerFooterConfig.evenPageHeaderId) {
|
||||
} else if (resolvedCreateType == null && key === 'evenAndOddHeaders' && value === BooleanNumber.TRUE && !(editArea === DocumentEditArea.HEADER ? headerFooterConfig.evenPageHeaderId : headerFooterConfig.evenPageFooterId)) {
|
||||
resolvedCreateType = editArea === DocumentEditArea.HEADER ? HeaderFooterType.EVEN_PAGE_HEADER : HeaderFooterType.EVEN_PAGE_FOOTER;
|
||||
}
|
||||
});
|
||||
@@ -105,13 +111,15 @@ export const CoreHeaderFooterCommand: ICommand<ICoreHeaderFooterParams> = {
|
||||
},
|
||||
};
|
||||
|
||||
interface IOpenHeaderFooterPanelParams { }
|
||||
interface IOpenHeaderFooterPanelParams {
|
||||
editArea?: DocumentEditArea.HEADER | DocumentEditArea.FOOTER;
|
||||
}
|
||||
|
||||
export const OpenHeaderFooterPanelCommand: ICommand<IOpenHeaderFooterPanelParams> = {
|
||||
id: 'doc.command.open-header-footer-panel',
|
||||
type: CommandType.COMMAND,
|
||||
|
||||
handler: async (accessor, _params: IOpenHeaderFooterPanelParams) => {
|
||||
handler: async (accessor, params: IOpenHeaderFooterPanelParams) => {
|
||||
const commandService = accessor.get(ICommandService);
|
||||
const instanceService = accessor.get(IUniverInstanceService);
|
||||
const renderManagerService = accessor.get(IRenderManagerService);
|
||||
@@ -133,10 +141,11 @@ export const OpenHeaderFooterPanelCommand: ICommand<IOpenHeaderFooterPanelParams
|
||||
const currentPage = selectionRenderService.getSegmentPage();
|
||||
const pageNumber = currentPage >= 0 && currentPage < pages.length ? currentPage : 0;
|
||||
const page = pages[pageNumber];
|
||||
viewModel.setEditArea(DocumentEditArea.HEADER);
|
||||
const editArea = params?.editArea ?? DocumentEditArea.HEADER;
|
||||
viewModel.setEditArea(editArea);
|
||||
const { createType, headerFooterId, sectionId } = getHeaderFooterTarget(
|
||||
viewModel,
|
||||
DocumentEditArea.HEADER,
|
||||
editArea,
|
||||
pageNumber,
|
||||
page
|
||||
);
|
||||
@@ -161,6 +170,20 @@ export const OpenHeaderFooterPanelCommand: ICommand<IOpenHeaderFooterPanelParams
|
||||
selectionRenderService.setSegment(headerFooterId);
|
||||
}
|
||||
|
||||
const segmentId = selectionRenderService.getSegment();
|
||||
const segmentSnapshot = docDataModel.getSelfOrHeaderFooterModel(segmentId)?.getSnapshot();
|
||||
if (!segmentSnapshot) {
|
||||
return false;
|
||||
}
|
||||
const offset = findFirstCursorOffset(segmentSnapshot);
|
||||
accessor.get(DocSelectionManagerService).replaceDocRanges([{
|
||||
startOffset: offset,
|
||||
endOffset: offset,
|
||||
collapsed: true,
|
||||
segmentId,
|
||||
segmentPage: pageNumber,
|
||||
}], { unitId, subUnitId: unitId });
|
||||
selectionRenderService.focus();
|
||||
return commandService.executeCommand(SidebarDocHeaderFooterPanelOperation.id, { value: 'open' });
|
||||
},
|
||||
};
|
||||
@@ -244,7 +267,7 @@ export const CloseHeaderFooterCommand: ICommand<ICloseHeaderFooterParams> = {
|
||||
]);
|
||||
});
|
||||
|
||||
commandService.executeCommand(SidebarDocHeaderFooterPanelOperation.id, { value: 'close' });
|
||||
await commandService.executeCommand(SidebarDocHeaderFooterPanelOperation.id, { value: 'close' });
|
||||
|
||||
return true;
|
||||
},
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* 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, ICommand, ITextRange } from '@univerjs/core';
|
||||
import type { DateTimeFieldFormat } from '../../utils/date-time-field';
|
||||
import {
|
||||
BooleanNumber,
|
||||
CommandType,
|
||||
CustomRangeType,
|
||||
DataStreamTreeTokenType,
|
||||
generateRandomId,
|
||||
ICommandService,
|
||||
IPermissionService,
|
||||
IUniverInstanceService,
|
||||
LOCALE_META,
|
||||
LocaleService,
|
||||
UniverInstanceType,
|
||||
} from '@univerjs/core';
|
||||
import {
|
||||
canEditDocumentTargets,
|
||||
DocSelectionManagerService,
|
||||
DocSkeletonManagerService,
|
||||
getDocumentEditTargetObjectIds,
|
||||
InsertTextCommand,
|
||||
} from '@univerjs/docs';
|
||||
import { IRenderManagerService } from '@univerjs/engine-render';
|
||||
import { getTextRunForSelection } from '../../basics/paragraph';
|
||||
import { DocMenuStyleService } from '../../services/doc-menu-style.service';
|
||||
import { createDateTimeField } from '../../utils/date-time-field';
|
||||
|
||||
export interface IInsertHeaderFooterFieldParams {
|
||||
unitId: string;
|
||||
fieldType: 'PAGE' | 'NUMPAGES' | 'DATE' | 'TIME';
|
||||
selection?: ITextRange;
|
||||
segmentId?: string;
|
||||
segmentPage?: number;
|
||||
expectedDataStream?: string;
|
||||
format?: DateTimeFieldFormat;
|
||||
automatic?: boolean;
|
||||
}
|
||||
|
||||
export const InsertHeaderFooterFieldCommand: ICommand<IInsertHeaderFooterFieldParams> = {
|
||||
id: 'doc.command.insert-header-footer-field',
|
||||
type: CommandType.COMMAND,
|
||||
handler(accessor, params) {
|
||||
if (!params || !['PAGE', 'NUMPAGES', 'DATE', 'TIME'].includes(params.fieldType)) {
|
||||
return false;
|
||||
}
|
||||
const model = accessor.get(IUniverInstanceService).getUnit<DocumentDataModel>(params.unitId, UniverInstanceType.UNIVER_DOC);
|
||||
const active = accessor.get(DocSelectionManagerService).getActiveTextRange();
|
||||
const selection = params.selection ?? active;
|
||||
const segmentId = params.segmentId ?? active?.segmentId;
|
||||
const snapshot = model?.getSnapshot();
|
||||
if (!model || snapshot?.disabled || !selection || !segmentId ||
|
||||
(!snapshot?.headers?.[segmentId] && !snapshot?.footers?.[segmentId])) {
|
||||
return false;
|
||||
}
|
||||
const body = model.getSelfOrHeaderFooterModel(segmentId)?.getBody();
|
||||
if (!body || (params.expectedDataStream != null && params.expectedDataStream !== body.dataStream) ||
|
||||
!Number.isInteger(selection.startOffset) || !Number.isInteger(selection.endOffset) ||
|
||||
selection.startOffset < 0 || selection.endOffset < selection.startOffset || selection.endOffset > body.dataStream.length - 2) {
|
||||
return false;
|
||||
}
|
||||
if (!canEditDocumentTargets(accessor.get(IPermissionService), params.unitId, getDocumentEditTargetObjectIds(model, segmentId, selection))) {
|
||||
return false;
|
||||
}
|
||||
if (body.customRanges?.some((range) => range.rangeType === CustomRangeType.FIELD &&
|
||||
range.properties?.locked === BooleanNumber.TRUE && selection.startOffset <= range.endIndex &&
|
||||
selection.endOffset > range.startIndex)) {
|
||||
return false;
|
||||
}
|
||||
const locale = LOCALE_META[accessor.get(LocaleService).getCurrentLocale()].tag;
|
||||
let properties;
|
||||
if (params.fieldType === 'DATE' || params.fieldType === 'TIME') {
|
||||
const format = params.format ?? (params.fieldType === 'TIME' ? 'time' : 'short');
|
||||
if (!['short', 'long', 'time'].includes(format)) {
|
||||
return false;
|
||||
}
|
||||
properties = createDateTimeField(format, locale, new Date());
|
||||
} else {
|
||||
const skeleton = accessor.get(IRenderManagerService).getRenderUnitById(params.unitId)?.with(DocSkeletonManagerService).getSkeleton();
|
||||
const pages = skeleton?.getSkeletonData()?.pages;
|
||||
if (!pages?.length) {
|
||||
return false;
|
||||
}
|
||||
const pageIndex = params.segmentPage ?? active?.segmentPage ?? 0;
|
||||
const page = pages[Math.max(0, pageIndex)];
|
||||
if (!page) {
|
||||
return false;
|
||||
}
|
||||
properties = {
|
||||
fieldType: params.fieldType,
|
||||
instruction: params.fieldType,
|
||||
cachedResult: String(params.fieldType === 'PAGE' ? page.pageNumber : skeleton?.getCompletedPageCount() ?? '…'),
|
||||
};
|
||||
}
|
||||
const automatic = params.automatic !== false || params.fieldType === 'PAGE' || params.fieldType === 'NUMPAGES';
|
||||
const dataStream = automatic
|
||||
? `${DataStreamTreeTokenType.CUSTOM_RANGE_START}${properties.cachedResult}${DataStreamTreeTokenType.CUSTOM_RANGE_END}`
|
||||
: properties.cachedResult;
|
||||
const style = accessor.get(DocMenuStyleService);
|
||||
const textRun = getTextRunForSelection(body, selection, style.getDefaultStyle(), style.getStyleCache(), false);
|
||||
return accessor.get(ICommandService).syncExecuteCommand(InsertTextCommand.id, {
|
||||
unitId: params.unitId,
|
||||
segmentId,
|
||||
range: selection,
|
||||
debounce: false,
|
||||
body: {
|
||||
dataStream,
|
||||
textRuns: [{ ...textRun, ts: { ...textRun.ts, lang: locale }, st: 0, ed: dataStream.length }],
|
||||
customRanges: automatic
|
||||
? [{
|
||||
startIndex: 0,
|
||||
endIndex: dataStream.length - 1,
|
||||
rangeId: generateRandomId(),
|
||||
rangeType: CustomRangeType.FIELD,
|
||||
wholeEntity: false,
|
||||
properties: { ...properties, sourceKind: 'complex' },
|
||||
}]
|
||||
: [],
|
||||
},
|
||||
textRanges: [{
|
||||
...selection,
|
||||
segmentId,
|
||||
segmentPage: params.segmentPage ?? active?.segmentPage,
|
||||
startOffset: selection.startOffset + dataStream.length,
|
||||
endOffset: selection.startOffset + dataStream.length,
|
||||
collapsed: true,
|
||||
}],
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* 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, ICommand, ICustomRange, IDocumentBody, JSONXActions } from '@univerjs/core';
|
||||
import {
|
||||
BooleanNumber,
|
||||
CommandType,
|
||||
CustomRangeType,
|
||||
DataStreamTreeTokenType,
|
||||
getRichTextEditPath,
|
||||
ICommandService,
|
||||
IPermissionService,
|
||||
IUniverInstanceService,
|
||||
JSONX,
|
||||
LOCALE_META,
|
||||
LocaleService,
|
||||
TextX,
|
||||
UniverInstanceType,
|
||||
} from '@univerjs/core';
|
||||
import {
|
||||
canEditDocumentTargets,
|
||||
DocSelectionManagerService,
|
||||
DocStateChangeManagerService,
|
||||
getDocumentEditTargetObjectIds,
|
||||
RichTextEditingMutation,
|
||||
transformDocumentTextRanges,
|
||||
} from '@univerjs/docs';
|
||||
import { resolveDateTimeField } from '../../utils/date-time-field';
|
||||
|
||||
export const RefreshHeaderFooterFieldsCommand: ICommand<{ unitId: string; onOpen?: boolean; selectionOnly?: boolean }> = {
|
||||
id: 'doc.command.refresh-header-footer-fields',
|
||||
type: CommandType.COMMAND,
|
||||
handler(accessor, params) {
|
||||
if (!params) {
|
||||
return false;
|
||||
}
|
||||
const model = accessor.get(IUniverInstanceService).getUnit<DocumentDataModel>(params.unitId, UniverInstanceType.UNIVER_DOC);
|
||||
const snapshot = model?.getSnapshot();
|
||||
if (!model || !snapshot || snapshot.disabled) {
|
||||
return false;
|
||||
}
|
||||
const permissions = accessor.get(IPermissionService);
|
||||
const locale = LOCALE_META[accessor.get(LocaleService).getCurrentLocale()].tag;
|
||||
const now = new Date();
|
||||
const selections = accessor.get(DocSelectionManagerService).getTextRanges({ unitId: params.unitId, subUnitId: params.unitId }) ?? [];
|
||||
let actions: JSONXActions = null;
|
||||
for (const [segmentId, segment] of Object.entries({ '': { body: snapshot.body }, ...snapshot.headers, ...snapshot.footers })) {
|
||||
if (!segment.body) {
|
||||
continue;
|
||||
}
|
||||
const textX = refreshDateTimeFields(segment.body, locale, now, (field) =>
|
||||
(!params.selectionOnly || selections.some((selection) => (selection.segmentId ?? '') === segmentId &&
|
||||
selection.startOffset <= field.endIndex && selection.endOffset > field.startIndex)) && canEditDocumentTargets(
|
||||
permissions,
|
||||
params.unitId,
|
||||
getDocumentEditTargetObjectIds(model, segmentId, { startOffset: field.startIndex, endOffset: field.endIndex + 1 })
|
||||
));
|
||||
if (textX.length) {
|
||||
actions = JSONX.compose(actions, JSONX.getInstance().editOp(textX, getRichTextEditPath(model, segmentId)));
|
||||
}
|
||||
}
|
||||
if (JSONX.isNoop(actions)) {
|
||||
return true;
|
||||
}
|
||||
if (!params.onOpen) {
|
||||
accessor.get(DocStateChangeManagerService).flushPendingChanges(params.unitId);
|
||||
}
|
||||
return Boolean(accessor.get(ICommandService).syncExecuteCommand(RichTextEditingMutation.id, {
|
||||
unitId: params.unitId,
|
||||
actions,
|
||||
textRanges: transformDocumentTextRanges(actions, [...selections]),
|
||||
noHistory: params.onOpen === true,
|
||||
debounce: false,
|
||||
trigger: RefreshHeaderFooterFieldsCommand.id,
|
||||
}));
|
||||
},
|
||||
};
|
||||
|
||||
export const UpdateSelectedDocFieldsCommand: ICommand = {
|
||||
id: 'doc.command.update-selected-fields',
|
||||
type: CommandType.COMMAND,
|
||||
handler(accessor) {
|
||||
const model = accessor.get(IUniverInstanceService).getCurrentUnitOfType<DocumentDataModel>(UniverInstanceType.UNIVER_DOC);
|
||||
if (!model) {
|
||||
return false;
|
||||
}
|
||||
return accessor.get(ICommandService).syncExecuteCommand(RefreshHeaderFooterFieldsCommand.id, {
|
||||
unitId: model.getUnitId(),
|
||||
selectionOnly: true,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
function refreshDateTimeFields(body: IDocumentBody, locale: string, date: Date, canEdit: (field: ICustomRange) => boolean) {
|
||||
const textX = new TextX();
|
||||
let cursor = 0;
|
||||
const ranges = [...body.customRanges ?? []].sort((a, b) => a.startIndex - b.startIndex);
|
||||
for (const field of ranges) {
|
||||
const properties = field.properties;
|
||||
if (field.rangeType !== CustomRangeType.FIELD || properties?.locked === BooleanNumber.TRUE ||
|
||||
(properties?.fieldType !== 'DATE' && properties?.fieldType !== 'TIME') || !canEdit(field) ||
|
||||
ranges.some((range) => range !== field && range.startIndex > field.startIndex && range.endIndex < field.endIndex)) {
|
||||
continue;
|
||||
}
|
||||
const start = field.startIndex;
|
||||
const run = body.textRuns?.find((run) => run.st <= start + 1 && run.ed > start + 1);
|
||||
const value = resolveDateTimeField(properties.instruction, run?.ts?.lang ?? locale, date);
|
||||
if (value == null || start < cursor || (body.dataStream.slice(start + 1, field.endIndex) === value && properties.cachedResult === value)) {
|
||||
continue;
|
||||
}
|
||||
const dataStream = `${DataStreamTreeTokenType.CUSTOM_RANGE_START}${value}${DataStreamTreeTokenType.CUSTOM_RANGE_END}`;
|
||||
textX.retain(start - cursor).delete(field.endIndex - start + 1).insert(dataStream.length, {
|
||||
dataStream,
|
||||
textRuns: run ? [{ ...run, st: 0, ed: dataStream.length }] : [],
|
||||
customRanges: [{ ...field, startIndex: 0, endIndex: dataStream.length - 1, properties: { ...properties, cachedResult: value } }],
|
||||
});
|
||||
cursor = field.endIndex + 1;
|
||||
}
|
||||
return textX.serialize();
|
||||
}
|
||||
@@ -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 { ICommand } from '@univerjs/core';
|
||||
import { IRibbonService } from '@univerjs/ui';
|
||||
import { DOC_HEADER_FOOTER_RIBBON_TAB } from '../../views/header-footer/panel/component-name';
|
||||
import { SidebarDocHeaderFooterPanelOperation } from './doc-header-footer-panel.operation';
|
||||
|
||||
// Preserve the existing command contract while desktop uses the contextual ribbon.
|
||||
export const DocHeaderFooterRibbonOperation: ICommand<{ value: string }> = {
|
||||
id: SidebarDocHeaderFooterPanelOperation.id,
|
||||
type: SidebarDocHeaderFooterPanelOperation.type,
|
||||
handler(accessor, params) {
|
||||
const ribbon = accessor.get(IRibbonService);
|
||||
if (params?.value === 'open') {
|
||||
ribbon.showContextualTab(DOC_HEADER_FOOTER_RIBBON_TAB, { activate: true });
|
||||
} else {
|
||||
ribbon.hideContextualTab(DOC_HEADER_FOOTER_RIBBON_TAB);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
};
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* 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, IDocumentData } from '@univerjs/core';
|
||||
import type { RenderUnit } from '@univerjs/engine-render';
|
||||
import {
|
||||
DocumentFlavor,
|
||||
IUniverInstanceService,
|
||||
LocaleService,
|
||||
LocaleType,
|
||||
Univer,
|
||||
UniverInstanceType,
|
||||
} from '@univerjs/core';
|
||||
import { DocLayoutExecutorService, DocSelectionManagerService, DocSkeletonManagerService } from '@univerjs/docs';
|
||||
import {
|
||||
CanvasColorService,
|
||||
DocumentEditArea,
|
||||
ICanvasColorService,
|
||||
IRenderManagerService,
|
||||
RenderManagerService,
|
||||
} from '@univerjs/engine-render';
|
||||
import {
|
||||
DesktopRibbonService,
|
||||
IMenuManagerService,
|
||||
IRibbonService,
|
||||
MenuManagerPosition,
|
||||
MenuManagerService,
|
||||
RibbonPosition,
|
||||
} from '@univerjs/ui';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import enUS from '../../locale/en-US';
|
||||
import { headerFooterRibbonSchema } from '../../menu/header-footer-ribbon';
|
||||
import { DOC_HEADER_FOOTER_RIBBON_TAB } from '../../views/header-footer/panel/component-name';
|
||||
import { DocHeaderFooterRibbonController } from '../doc-header-footer-ribbon.controller';
|
||||
|
||||
const cleanups: Array<() => void> = [];
|
||||
afterEach(() => {
|
||||
cleanups.splice(0).reverse().forEach((dispose) => dispose());
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
function createTestBed() {
|
||||
vi.useFakeTimers();
|
||||
const univer = new Univer();
|
||||
cleanups.push(() => univer.dispose());
|
||||
const injector = univer.__getInjector();
|
||||
injector.add([IRenderManagerService, { useClass: RenderManagerService }]);
|
||||
injector.add([IMenuManagerService, { useClass: MenuManagerService }]);
|
||||
injector.add([IRibbonService, { useClass: DesktopRibbonService }]);
|
||||
injector.add([ICanvasColorService, { useClass: CanvasColorService }]);
|
||||
injector.add([DocLayoutExecutorService]);
|
||||
injector.add([DocSelectionManagerService]);
|
||||
const locale = injector.get(LocaleService);
|
||||
locale.load({ [LocaleType.EN_US]: enUS });
|
||||
locale.setLocale(LocaleType.EN_US);
|
||||
locale.setDirection('ltr');
|
||||
const menus = injector.get(IMenuManagerService);
|
||||
menus.mergeMenu(headerFooterRibbonSchema);
|
||||
for (const tab of [RibbonPosition.START, RibbonPosition.INSERT]) {
|
||||
menus.mergeMenu({ [MenuManagerPosition.RIBBON]: { [tab]: { group: { item: { menuItemFactory: () => ({ id: tab, type: 0 }) } } } } });
|
||||
}
|
||||
const model = univer.createUnit<IDocumentData, DocumentDataModel>(UniverInstanceType.UNIVER_DOC, {
|
||||
id: 'ribbon-doc',
|
||||
body: { dataStream: 'A\r\n', paragraphs: [{ paragraphId: 'ribbon-paragraph', startIndex: 1 }] },
|
||||
documentStyle: { documentFlavor: DocumentFlavor.TRADITIONAL, pageSize: { width: 300, height: 400 } },
|
||||
});
|
||||
const instances = injector.get(IUniverInstanceService);
|
||||
const render = injector.get(IRenderManagerService).createRender(model.getUnitId()) as RenderUnit;
|
||||
render.deactivate();
|
||||
render.addRenderDependencies([[DocSkeletonManagerService], [DocHeaderFooterRibbonController]]);
|
||||
const controller = render.with(DocHeaderFooterRibbonController);
|
||||
const view = render.with(DocSkeletonManagerService).getViewModel();
|
||||
const ribbon = injector.get(IRibbonService);
|
||||
const state = { active: '', tabs: [] as string[] };
|
||||
const active = ribbon.activatedTab$.subscribe((tab) => state.active = tab);
|
||||
const visible = ribbon.ribbon$.subscribe((tabs) => state.tabs = tabs.map((tab) => tab.key));
|
||||
cleanups.push(() => {
|
||||
active.unsubscribe();
|
||||
visible.unsubscribe();
|
||||
});
|
||||
instances.focusUnit(model.getUnitId());
|
||||
return { univer, injector, instances, view, ribbon, state, controller };
|
||||
}
|
||||
|
||||
describe('DocHeaderFooterRibbonController', () => {
|
||||
it('keeps the tab visible when focus moves between editing documents in either registration order', () => {
|
||||
const { univer, injector, instances, view, state } = createTestBed();
|
||||
const second = univer.createUnit<IDocumentData, DocumentDataModel>(UniverInstanceType.UNIVER_DOC, {
|
||||
id: 'second-ribbon-doc',
|
||||
body: { dataStream: 'B\r\n', paragraphs: [{ paragraphId: 'second-paragraph', startIndex: 1 }] },
|
||||
documentStyle: { documentFlavor: DocumentFlavor.TRADITIONAL },
|
||||
});
|
||||
const render = injector.get(IRenderManagerService).createRender(second.getUnitId()) as RenderUnit;
|
||||
render.deactivate();
|
||||
render.addRenderDependencies([[DocSkeletonManagerService], [DocHeaderFooterRibbonController]]);
|
||||
render.with(DocHeaderFooterRibbonController);
|
||||
const secondView = render.with(DocSkeletonManagerService).getViewModel();
|
||||
view.setEditArea(DocumentEditArea.HEADER);
|
||||
secondView.setEditArea(DocumentEditArea.FOOTER);
|
||||
instances.focusUnit(second.getUnitId());
|
||||
expect(state.tabs).toContain(DOC_HEADER_FOOTER_RIBBON_TAB);
|
||||
instances.focusUnit('ribbon-doc');
|
||||
expect(state.tabs).toContain(DOC_HEADER_FOOTER_RIBBON_TAB);
|
||||
expect(state.active).toBe(DOC_HEADER_FOOTER_RIBBON_TAB);
|
||||
secondView.setEditArea(DocumentEditArea.BODY);
|
||||
instances.focusUnit(second.getUnitId());
|
||||
expect(state.tabs).not.toContain(DOC_HEADER_FOOTER_RIBBON_TAB);
|
||||
});
|
||||
|
||||
it('activates on entry, preserves a manual tab while editing and restores it on exit', () => {
|
||||
const { view, ribbon, state, controller } = createTestBed();
|
||||
ribbon.setActivatedTab(RibbonPosition.INSERT);
|
||||
view.setEditArea(DocumentEditArea.HEADER);
|
||||
expect(state.active).toBe(DOC_HEADER_FOOTER_RIBBON_TAB);
|
||||
expect(state.tabs).toContain(DOC_HEADER_FOOTER_RIBBON_TAB);
|
||||
ribbon.setActivatedTab(RibbonPosition.START);
|
||||
view.setEditArea(DocumentEditArea.FOOTER);
|
||||
expect(state.active).toBe(RibbonPosition.START);
|
||||
view.setEditArea(DocumentEditArea.BODY);
|
||||
expect(state.tabs).not.toContain(DOC_HEADER_FOOTER_RIBBON_TAB);
|
||||
view.setEditArea(DocumentEditArea.HEADER);
|
||||
controller.dispose();
|
||||
expect(state.tabs).not.toContain(DOC_HEADER_FOOTER_RIBBON_TAB);
|
||||
expect(state.active).toBe(RibbonPosition.START);
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
AddImageIcon,
|
||||
AlignTextBothIcon,
|
||||
BoldIcon,
|
||||
CloseIcon,
|
||||
ColumnIcon,
|
||||
CopyDoubleIcon,
|
||||
CutIcon,
|
||||
@@ -76,8 +77,12 @@ import { ComponentManager, IconManager } from '@univerjs/ui';
|
||||
import { DOC_LAYOUT_RECOVERY_COMPONENT, DocLayoutRecovery } from '../views/DocLayoutRecovery';
|
||||
import { DOC_PASTE_OPTIONS_COMPONENT, DocPasteOptions } from '../views/DocPasteOptions';
|
||||
import { FLOAT_MENU_COMPONENT_KEY, FloatToolbar } from '../views/float-toolbar/FloatToolbar';
|
||||
import { COMPONENT_DOC_HEADER_FOOTER_PANEL } from '../views/header-footer/panel/component-name';
|
||||
import {
|
||||
COMPONENT_DOC_HEADER_FOOTER_PANEL,
|
||||
DOC_HEADER_FOOTER_RIBBON_CONTROL,
|
||||
} from '../views/header-footer/panel/component-name';
|
||||
import { DocHeaderFooterPanel } from '../views/header-footer/panel/DocHeaderFooterPanel';
|
||||
import { DocHeaderFooterRibbonControl } from '../views/header-footer/panel/DocHeaderFooterRibbonControl';
|
||||
import {
|
||||
DefaultTextColorIcon,
|
||||
DocParagraphBackgroundColorSwatchIcon0,
|
||||
@@ -149,6 +154,7 @@ export class ComponentsController extends Disposable {
|
||||
BoldIcon,
|
||||
CopyDoubleIcon,
|
||||
ColumnIcon,
|
||||
CloseIcon,
|
||||
CutIcon,
|
||||
DeleteColumnDoubleIcon,
|
||||
DeleteIcon,
|
||||
@@ -228,6 +234,7 @@ export class ComponentsController extends Disposable {
|
||||
}
|
||||
|
||||
private _registerComponents(): void {
|
||||
this.disposeWithMe(this._componentManager.register(DOC_HEADER_FOOTER_RIBBON_CONTROL, DocHeaderFooterRibbonControl));
|
||||
if (!this._componentManager.get(COMPONENT_DOC_HEADER_FOOTER_PANEL)) {
|
||||
this.disposeWithMe(this._componentManager.register(COMPONENT_DOC_HEADER_FOOTER_PANEL, DocHeaderFooterPanel));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* 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 type { IRenderContext, IRenderModule } from '@univerjs/engine-render';
|
||||
import { Disposable, DocumentFlavor, Inject, IUniverInstanceService, UniverInstanceType } from '@univerjs/core';
|
||||
import { DocSkeletonManagerService } from '@univerjs/docs';
|
||||
import { DocumentEditArea, IRenderManagerService } from '@univerjs/engine-render';
|
||||
import { IRibbonService } from '@univerjs/ui';
|
||||
import { combineLatest, distinctUntilChanged, map } from 'rxjs';
|
||||
import { DOC_HEADER_FOOTER_RIBBON_TAB } from '../views/header-footer/panel/component-name';
|
||||
|
||||
export class DocHeaderFooterRibbonController extends Disposable implements IRenderModule {
|
||||
private _visible = false;
|
||||
|
||||
constructor(
|
||||
private readonly _context: IRenderContext<DocumentDataModel>,
|
||||
@Inject(DocSkeletonManagerService) skeletonManager: DocSkeletonManagerService,
|
||||
@IUniverInstanceService private readonly _instanceService: IUniverInstanceService,
|
||||
@IRibbonService private readonly _ribbonService: IRibbonService,
|
||||
@IRenderManagerService private readonly _renderManager: IRenderManagerService
|
||||
) {
|
||||
super();
|
||||
this.disposeWithMe(combineLatest([
|
||||
skeletonManager.getViewModel().editAreaChange$,
|
||||
this._instanceService.focused$,
|
||||
]).pipe(
|
||||
map(([area, focused]) => focused === this._context.unitId &&
|
||||
this._context.unit.getDocumentStyle().documentFlavor === DocumentFlavor.TRADITIONAL &&
|
||||
(area === DocumentEditArea.HEADER || area === DocumentEditArea.FOOTER)),
|
||||
distinctUntilChanged()
|
||||
).subscribe((visible) => {
|
||||
if (visible === this._visible) {
|
||||
return;
|
||||
}
|
||||
this._visible = visible;
|
||||
if (visible) {
|
||||
this._ribbonService.showContextualTab(DOC_HEADER_FOOTER_RIBBON_TAB, { activate: true });
|
||||
} else if (!this._focusedDocumentEditsHeaderFooter()) {
|
||||
this._ribbonService.hideContextualTab(DOC_HEADER_FOOTER_RIBBON_TAB);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
private _focusedDocumentEditsHeaderFooter(): boolean {
|
||||
const focused = this._instanceService.getFocusedUnit();
|
||||
if (!focused || focused.type !== UniverInstanceType.UNIVER_DOC || focused.getUnitId() === this._context.unitId) {
|
||||
return false;
|
||||
}
|
||||
const manager = this._renderManager.getRenderUnitById(focused.getUnitId())?.with(DocSkeletonManagerService);
|
||||
const area = manager?.getViewModel().getEditArea();
|
||||
return (area === DocumentEditArea.HEADER || area === DocumentEditArea.FOOTER) &&
|
||||
manager?.getViewModel().getDataModel().getDocumentStyle().documentFlavor === DocumentFlavor.TRADITIONAL;
|
||||
}
|
||||
|
||||
override dispose(): void {
|
||||
if (this._visible && !this._focusedDocumentEditsHeaderFooter()) {
|
||||
this._ribbonService.hideContextualTab(DOC_HEADER_FOOTER_RIBBON_TAB);
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -183,7 +183,7 @@ export class DocMoveCursorController extends Disposable {
|
||||
}
|
||||
|
||||
const dataStreamLength = body.dataStream.length ?? Number.POSITIVE_INFINITY;
|
||||
const customRanges = docDataModel.getCustomRanges() ?? [];
|
||||
const customRanges = body.customRanges ?? [];
|
||||
|
||||
if (granularity !== 'character') {
|
||||
const nextOffset = this._getCursorOffsetByGranularity(
|
||||
@@ -357,7 +357,7 @@ export class DocMoveCursorController extends Disposable {
|
||||
}
|
||||
|
||||
const dataStreamLength = body.dataStream.length ?? Number.POSITIVE_INFINITY;
|
||||
const customRanges = docDataModel.getCustomRanges() ?? [];
|
||||
const customRanges = body.customRanges ?? [];
|
||||
|
||||
if (granularity !== 'character') {
|
||||
let cursorOffset: number;
|
||||
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* 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, IDocumentData } from '@univerjs/core';
|
||||
import type { IPageRenderConfig, RenderUnit, UniverRenderingContext } from '@univerjs/engine-render';
|
||||
import type { Subject } from 'rxjs';
|
||||
import { CustomRangeType, IUniverInstanceService, Univer, UniverInstanceType } from '@univerjs/core';
|
||||
import {
|
||||
DocLayoutExecutorService,
|
||||
DocSelectionManagerService,
|
||||
DocSkeletonManagerService,
|
||||
generateParagraphs,
|
||||
} from '@univerjs/docs';
|
||||
import {
|
||||
CanvasColorService,
|
||||
DocumentEditArea,
|
||||
Documents,
|
||||
ICanvasColorService,
|
||||
IRenderManagerService,
|
||||
NORMAL_TEXT_SELECTION_PLUGIN_STYLE,
|
||||
RenderManagerService,
|
||||
} from '@univerjs/engine-render';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { DocEventManagerService } from '../../../services/doc-event-manager.service';
|
||||
import { DocFieldRenderController } from '../doc-field.render-controller';
|
||||
|
||||
const cleanups: Array<() => void> = [];
|
||||
afterEach(() => {
|
||||
cleanups.splice(0).reverse().forEach((dispose) => dispose());
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function createTestBed() {
|
||||
const canvas = new Proxy({
|
||||
font: '',
|
||||
webkitBackingStorePixelRatio: 1,
|
||||
measureText: (text: string) => ({ width: text.length * 8, actualBoundingBoxAscent: 8, actualBoundingBoxDescent: 2, fontBoundingBoxAscent: 8, fontBoundingBoxDescent: 2 }),
|
||||
}, { get: (target, key) => key in target ? Reflect.get(target, key) : () => {} });
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(canvas as never);
|
||||
const univer = new Univer();
|
||||
cleanups.push(() => univer.dispose());
|
||||
const injector = univer.__getInjector();
|
||||
injector.add([IRenderManagerService, { useClass: RenderManagerService }]);
|
||||
injector.add([ICanvasColorService, { useClass: CanvasColorService }]);
|
||||
injector.add([DocLayoutExecutorService]);
|
||||
injector.add([DocSelectionManagerService]);
|
||||
const text = 'Body\rSecond\r\n';
|
||||
const paragraphs = generateParagraphs(text);
|
||||
paragraphs[1].paragraphStyle = { pageBreakBefore: 1 };
|
||||
const model = univer.createUnit<IDocumentData, DocumentDataModel>(UniverInstanceType.UNIVER_DOC, {
|
||||
id: 'field-chrome',
|
||||
body: { dataStream: text, paragraphs },
|
||||
documentStyle: { documentFlavor: 1, pageSize: { width: 300, height: 400 }, marginTop: 40, marginBottom: 40, marginLeft: 20, marginRight: 20, marginHeader: 10, defaultHeaderId: 'header' },
|
||||
headers: { header: { headerId: 'header', body: {
|
||||
dataStream: '\u001F1\u001E\r\n',
|
||||
paragraphs: [{ paragraphId: 'header-p', startIndex: 3 }],
|
||||
customRanges: [{ rangeId: 'page', startIndex: 0, endIndex: 2, rangeType: CustomRangeType.FIELD, properties: { fieldType: 'PAGE', instruction: 'PAGE', cachedResult: '1' } }],
|
||||
} } },
|
||||
});
|
||||
const render = injector.get(IRenderManagerService).createRender(model.getUnitId()) as RenderUnit;
|
||||
render.deactivate();
|
||||
render.addRenderDependencies([[DocSkeletonManagerService]]);
|
||||
const skeleton = render.with(DocSkeletonManagerService).getSkeleton();
|
||||
skeleton.calculate();
|
||||
skeleton.getViewModel().setEditArea(DocumentEditArea.HEADER);
|
||||
const documents = new Documents('field-chrome-doc', skeleton);
|
||||
render.mainComponent = documents;
|
||||
render.scene.addObject(documents);
|
||||
render.addRenderDependencies([[DocEventManagerService], [DocFieldRenderController]]);
|
||||
const controller = render.with(DocFieldRenderController);
|
||||
const events = render.with(DocEventManagerService);
|
||||
const selections = injector.get(DocSelectionManagerService);
|
||||
selections.__TEST_ONLY_setCurrentSelection({ unitId: model.getUnitId(), subUnitId: model.getUnitId() });
|
||||
injector.get(IUniverInstanceService).focusUnit(model.getUnitId());
|
||||
const pages = skeleton.getSkeletonData()!.pages;
|
||||
const fillRect = vi.fn();
|
||||
const ctx = { save: vi.fn(), restore: vi.fn(), beginPath: vi.fn(), rect: vi.fn(), clip: vi.fn(), fillRect, globalAlpha: 1 } as unknown as UniverRenderingContext;
|
||||
const paint = (pageIndex: number) => (documents as unknown as { _pageRender$: Subject<IPageRenderConfig> })._pageRender$.next({ page: pages[pageIndex], pageLeft: 0, pageTop: pageIndex * 420, ctx });
|
||||
return { controller, events, selections, render, pages, fillRect, paint };
|
||||
}
|
||||
|
||||
describe('DocFieldRenderController', () => {
|
||||
it('shades only the selected copy of a shared header field and removes shading outside it', () => {
|
||||
const { selections, pages, fillRect, paint } = createTestBed();
|
||||
expect(pages).toHaveLength(2);
|
||||
selections.__TEST_ONLY_add([{ startOffset: 1, endOffset: 1, collapsed: true, isActive: true, segmentId: 'header', segmentPage: 1 }]);
|
||||
paint(0);
|
||||
expect(fillRect).not.toHaveBeenCalled();
|
||||
paint(1);
|
||||
expect(fillRect).toHaveBeenCalledTimes(1);
|
||||
fillRect.mockClear();
|
||||
selections.replaceSelectionInfoWithoutRefresh({
|
||||
textRanges: [{ startOffset: 3, endOffset: 3, collapsed: true, isActive: true, segmentId: 'header', segmentPage: 1 }],
|
||||
rectRanges: [],
|
||||
segmentId: 'header',
|
||||
segmentPage: 1,
|
||||
isEditing: true,
|
||||
style: NORMAL_TEXT_SELECTION_PLUGIN_STYLE,
|
||||
});
|
||||
paint(1);
|
||||
expect(fillRect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps a text cursor while hovering fields, paints their bounds and releases subscriptions', () => {
|
||||
const { controller, events, render, fillRect, paint } = createTestBed();
|
||||
const bound = events.getCustomRangeBounds().find((item) => item.segmentPageIndex === 0)!;
|
||||
const rect = bound.rects[0];
|
||||
render.scene.onPointerMove$.emitEvent({ buttons: 0, offsetX: (rect.left + rect.right) / 2, offsetY: (rect.top + rect.bottom) / 2 } as never);
|
||||
expect(render.scene.getCursor()).not.toBe('pointer');
|
||||
paint(0);
|
||||
expect(fillRect).toHaveBeenCalledTimes(1);
|
||||
controller.dispose();
|
||||
fillRect.mockClear();
|
||||
paint(0);
|
||||
expect(fillRect).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* 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 type { Documents, IPageRenderConfig, IRenderContext, IRenderModule } from '@univerjs/engine-render';
|
||||
import { CustomRangeType, Disposable, Inject, IUniverInstanceService, ThemeService } from '@univerjs/core';
|
||||
import { DocSelectionManagerService } from '@univerjs/docs';
|
||||
import { DocumentEditArea } from '@univerjs/engine-render';
|
||||
import { DocEventManagerService } from '../../services/doc-event-manager.service';
|
||||
|
||||
export class DocFieldRenderController extends Disposable implements IRenderModule {
|
||||
private _hovered = new Set<string>();
|
||||
private _shade = '';
|
||||
|
||||
constructor(
|
||||
private readonly _context: IRenderContext<DocumentDataModel>,
|
||||
@Inject(DocEventManagerService) private readonly _events: DocEventManagerService,
|
||||
@Inject(DocSelectionManagerService) private readonly _selections: DocSelectionManagerService,
|
||||
@Inject(ThemeService) theme: ThemeService,
|
||||
@IUniverInstanceService private readonly _instances: IUniverInstanceService
|
||||
) {
|
||||
super();
|
||||
const documents = this._context.mainComponent as Documents;
|
||||
this.disposeWithMe(theme.currentTheme$.subscribe(() => {
|
||||
this._shade = theme.getColorFromTheme('gray.200');
|
||||
documents.makeDirty(true);
|
||||
}));
|
||||
this.disposeWithMe(this._events.hoverCustomRanges$.subscribe((ranges) => {
|
||||
this._hovered = new Set(ranges.filter(({ range }) => range.rangeType === CustomRangeType.FIELD)
|
||||
.map(({ range, segmentId, segmentPageIndex }) => `${segmentId ?? ''}:${segmentPageIndex}:${range.rangeId}`));
|
||||
documents.makeDirty(true);
|
||||
}));
|
||||
this.disposeWithMe(this._selections.textSelection$.subscribe(({ unitId }) => {
|
||||
if (unitId === this._context.unitId) {
|
||||
documents.makeDirty(true);
|
||||
}
|
||||
}));
|
||||
this.disposeWithMe(this._instances.focused$.subscribe(() => documents.makeDirty(true)));
|
||||
this.disposeWithMe(documents.getSkeleton()!.getViewModel().editAreaChange$.subscribe(() => documents.makeDirty(true)));
|
||||
this.disposeWithMe(documents.pageRender$.subscribe((config) => this._drawFields(config)));
|
||||
}
|
||||
|
||||
private _drawFields({ page, pageLeft, pageTop, ctx }: IPageRenderConfig): void {
|
||||
if (this._instances.getFocusedUnit()?.getUnitId() !== this._context.unitId) {
|
||||
return;
|
||||
}
|
||||
const active = this._getActiveFields();
|
||||
if (!active.size) {
|
||||
return;
|
||||
}
|
||||
const documents = this._context.mainComponent as Documents;
|
||||
const skeleton = documents.getSkeleton()!;
|
||||
const pageIndex = skeleton.getSkeletonData()!.pages.indexOf(page);
|
||||
const { docsLeft, docsTop } = documents.getOffsetConfig();
|
||||
const area = skeleton.getViewModel().getEditArea();
|
||||
const snapshot = this._context.unit.getSnapshot();
|
||||
const activeSegments = area === DocumentEditArea.HEADER ? snapshot.headers : snapshot.footers;
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.rect(pageLeft, pageTop, page.pageWidth, page.pageHeight);
|
||||
ctx.clip();
|
||||
ctx.fillStyle = this._shade;
|
||||
ctx.globalAlpha *= 0.45;
|
||||
for (const { customRange: field, segmentId, segmentPageIndex, rects } of this._events.getCustomRangeBounds()) {
|
||||
if (field.rangeType !== CustomRangeType.FIELD) {
|
||||
continue;
|
||||
}
|
||||
if (segmentId) {
|
||||
if (area === DocumentEditArea.BODY || segmentPageIndex !== pageIndex || !activeSegments?.[segmentId]) {
|
||||
continue;
|
||||
}
|
||||
} else if (area !== DocumentEditArea.BODY) {
|
||||
continue;
|
||||
}
|
||||
if (!active.has(`${segmentId ?? ''}:${segmentPageIndex}:${field.rangeId}`)) {
|
||||
continue;
|
||||
}
|
||||
for (const rect of rects) {
|
||||
ctx.fillRect(rect.left - docsLeft, rect.top - docsTop, rect.right - rect.left, rect.bottom - rect.top);
|
||||
}
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
private _getActiveFields(): Set<string> {
|
||||
const active = new Set(this._hovered);
|
||||
const selections = this._selections.getTextRanges({ unitId: this._context.unitId, subUnitId: this._context.unitId }) ?? [];
|
||||
for (const selection of selections) {
|
||||
const segmentId = selection.segmentId ?? '';
|
||||
const fields = this._context.unit.getSelfOrHeaderFooterModel(segmentId)?.getBody()?.customRanges ?? [];
|
||||
for (const field of fields) {
|
||||
if (field.rangeType === CustomRangeType.FIELD && selection.startOffset <= field.endIndex && selection.endOffset > field.startIndex) {
|
||||
active.add(`${segmentId}:${segmentId ? selection.segmentPage : -1}:${field.rangeId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return active;
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { DocumentDataModel } from '@univerjs/core';
|
||||
import {
|
||||
Disposable,
|
||||
ICommandService,
|
||||
@@ -34,16 +35,22 @@ import {
|
||||
IUIPartsService,
|
||||
} from '@univerjs/ui';
|
||||
import { CoreHeaderFooterCommand, OpenHeaderFooterPanelCommand } from '../commands/commands/doc-header-footer.command';
|
||||
import { SidebarDocHeaderFooterPanelOperation } from '../commands/operations/doc-header-footer-panel.operation';
|
||||
import { InsertHeaderFooterFieldCommand } from '../commands/commands/insert-header-footer-field.command';
|
||||
import {
|
||||
RefreshHeaderFooterFieldsCommand,
|
||||
UpdateSelectedDocFieldsCommand,
|
||||
} from '../commands/commands/refresh-header-footer-fields.command';
|
||||
import { DocHeaderFooterRibbonOperation } from '../commands/operations/doc-header-footer-ribbon.operation';
|
||||
import { OpenDocParagraphPermissionOperation } from '../commands/operations/paragraph-permission.operation';
|
||||
import { OpenDocPermissionPanelOperation } from '../commands/operations/permission-panel.operation';
|
||||
import { headerFooterRibbonSchema } from '../menu/header-footer-ribbon';
|
||||
import { floatToolbarMenuSchema, menuSchema } from '../menu/schema';
|
||||
import {
|
||||
IDocEmbedInteractionBoundaryService,
|
||||
IDocEmbedRuntimeFocusCoordinator,
|
||||
} from '../services/doc-embed-integration.service';
|
||||
import { DocSelectionRenderService } from '../services/selection/doc-selection-render.service';
|
||||
import { TabShortCut } from '../shortcuts/format.shortcut';
|
||||
import { TabShortCut, UpdateDocFieldsShortcut } from '../shortcuts/format.shortcut';
|
||||
import {
|
||||
AlignCenterShortCut,
|
||||
AlignJustifyShortCut,
|
||||
@@ -87,6 +94,7 @@ export class DocUIController extends Disposable {
|
||||
private _initMenus(): void {
|
||||
this._menuManagerService.appendRootMenu(floatToolbarMenuSchema);
|
||||
this._menuManagerService.mergeMenu(menuSchema);
|
||||
this._menuManagerService.mergeMenu(headerFooterRibbonSchema);
|
||||
}
|
||||
|
||||
private _initShortCut() {
|
||||
@@ -104,6 +112,7 @@ export class DocUIController extends Disposable {
|
||||
OrderListShortCut,
|
||||
BulletListShortCut,
|
||||
TabShortCut,
|
||||
UpdateDocFieldsShortcut,
|
||||
].forEach((shortcut) => {
|
||||
this.disposeWithMe(this._shortcutService.registerShortcut(shortcut));
|
||||
});
|
||||
@@ -113,6 +122,7 @@ export class DocUIController extends Disposable {
|
||||
this._initMenus();
|
||||
this._initFocusHandler();
|
||||
this._initCommands();
|
||||
this._initDateTimeFields();
|
||||
this._initUiParts();
|
||||
this._initShortCut();
|
||||
}
|
||||
@@ -122,11 +132,24 @@ export class DocUIController extends Disposable {
|
||||
OpenDocPermissionPanelOperation,
|
||||
OpenDocParagraphPermissionOperation,
|
||||
CoreHeaderFooterCommand,
|
||||
InsertHeaderFooterFieldCommand,
|
||||
RefreshHeaderFooterFieldsCommand,
|
||||
UpdateSelectedDocFieldsCommand,
|
||||
OpenHeaderFooterPanelCommand,
|
||||
SidebarDocHeaderFooterPanelOperation,
|
||||
DocHeaderFooterRibbonOperation,
|
||||
].forEach((command) => this.disposeWithMe(this._commandService.registerCommand(command)));
|
||||
}
|
||||
|
||||
private _initDateTimeFields(): void {
|
||||
const refreshed = new WeakSet<DocumentDataModel>();
|
||||
this.disposeWithMe(this._univerInstanceService.getCurrentTypeOfUnit$<DocumentDataModel>(UniverInstanceType.UNIVER_DOC).subscribe((model) => {
|
||||
if (model && !refreshed.has(model)) {
|
||||
refreshed.add(model);
|
||||
this._commandService.syncExecuteCommand(RefreshHeaderFooterFieldsCommand.id, { unitId: model.getUnitId(), onOpen: true });
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
private _initFocusHandler(): void {
|
||||
this.disposeWithMe(
|
||||
this._layoutService.registerFocusHandler(UniverInstanceType.UNIVER_DOC, (unitId: string) => {
|
||||
|
||||
@@ -118,6 +118,19 @@ const locale: typeof enUS = {
|
||||
deleteTable: 'حذف الجدول',
|
||||
},
|
||||
headerFooter: {
|
||||
navigation: 'التنقل',
|
||||
options: 'الخيارات',
|
||||
position: 'الموضع',
|
||||
refreshFields: 'تحديث الحقول',
|
||||
pageNumber: 'رقم الصفحة',
|
||||
currentPage: 'الصفحة الحالية',
|
||||
totalPages: 'إجمالي الصفحات',
|
||||
dateTime: 'التاريخ والوقت',
|
||||
updateAutomatically: 'تحديث تلقائي',
|
||||
insertFailed: 'تغير المستند أو لا يمكن تحريره. أغلق مربع الحوار وحاول مرة أخرى.',
|
||||
goToHeader: 'انتقال إلى الرأس',
|
||||
goToFooter: 'انتقال إلى التذييل',
|
||||
|
||||
linkToPrevious: 'ارتباط بالسابق',
|
||||
header: 'رأس',
|
||||
footer: 'تذييل',
|
||||
|
||||
@@ -118,6 +118,19 @@ const locale: typeof enUS = {
|
||||
deleteTable: 'Elimina taula',
|
||||
},
|
||||
headerFooter: {
|
||||
navigation: 'Navegació',
|
||||
options: 'Opcions',
|
||||
position: 'Posició',
|
||||
refreshFields: 'Actualitza els camps',
|
||||
pageNumber: 'Número de pàgina',
|
||||
currentPage: 'Pàgina actual',
|
||||
totalPages: 'Total de pàgines',
|
||||
dateTime: 'Data i hora',
|
||||
updateAutomatically: 'Actualitza automàticament',
|
||||
insertFailed: 'El document ha canviat o no es pot editar. Tanqueu aquest diàleg i torneu-ho a provar.',
|
||||
goToHeader: 'Ves a la capçalera',
|
||||
goToFooter: 'Ves al peu de pàgina',
|
||||
|
||||
linkToPrevious: 'Enllaça amb l\'anterior',
|
||||
header: 'Capçalera',
|
||||
footer: 'Peu de pàgina',
|
||||
|
||||
@@ -118,6 +118,19 @@ const locale: typeof enUS = {
|
||||
deleteTable: 'Tabelle löschen',
|
||||
},
|
||||
headerFooter: {
|
||||
navigation: 'Navigation',
|
||||
options: 'Optionen',
|
||||
position: 'Position',
|
||||
refreshFields: 'Felder aktualisieren',
|
||||
pageNumber: 'Seitenzahl',
|
||||
currentPage: 'Aktuelle Seite',
|
||||
totalPages: 'Gesamtseitenzahl',
|
||||
dateTime: 'Datum und Uhrzeit',
|
||||
updateAutomatically: 'Automatisch aktualisieren',
|
||||
insertFailed: 'Das Dokument wurde geändert oder kann nicht bearbeitet werden. Schließen Sie den Dialog und versuchen Sie es erneut.',
|
||||
goToHeader: 'Zur Kopfzeile',
|
||||
goToFooter: 'Zur Fußzeile',
|
||||
|
||||
linkToPrevious: 'Mit vorheriger verknüpfen',
|
||||
header: 'Kopfzeile',
|
||||
footer: 'Fußzeile',
|
||||
|
||||
@@ -116,6 +116,19 @@ const locale = {
|
||||
deleteTable: 'Delete table',
|
||||
},
|
||||
headerFooter: {
|
||||
navigation: 'Navigation',
|
||||
options: 'Options',
|
||||
position: 'Position',
|
||||
refreshFields: 'Update fields',
|
||||
pageNumber: 'Page number',
|
||||
currentPage: 'Current page',
|
||||
totalPages: 'Total pages',
|
||||
dateTime: 'Date & time',
|
||||
updateAutomatically: 'Update automatically',
|
||||
insertFailed: 'The document changed or cannot be edited. Close this dialog and try again.',
|
||||
goToHeader: 'Go to header',
|
||||
goToFooter: 'Go to footer',
|
||||
|
||||
header: 'Header',
|
||||
footer: 'Footer',
|
||||
panel: 'Header & Footer Settings',
|
||||
|
||||
@@ -118,6 +118,19 @@ const locale: typeof enUS = {
|
||||
deleteTable: 'Eliminar tabla',
|
||||
},
|
||||
headerFooter: {
|
||||
navigation: 'Navegación',
|
||||
options: 'Opciones',
|
||||
position: 'Posición',
|
||||
refreshFields: 'Actualizar campos',
|
||||
pageNumber: 'Número de página',
|
||||
currentPage: 'Página actual',
|
||||
totalPages: 'Total de páginas',
|
||||
dateTime: 'Fecha y hora',
|
||||
updateAutomatically: 'Actualizar automáticamente',
|
||||
insertFailed: 'El documento ha cambiado o no se puede editar. Cierre este diálogo e inténtelo de nuevo.',
|
||||
goToHeader: 'Ir al encabezado',
|
||||
goToFooter: 'Ir al pie de página',
|
||||
|
||||
linkToPrevious: 'Vincular al anterior',
|
||||
header: 'Encabezado',
|
||||
footer: 'Pie de página',
|
||||
|
||||
@@ -118,6 +118,19 @@ const locale: typeof enUS = {
|
||||
deleteTable: 'حذف جدول',
|
||||
},
|
||||
headerFooter: {
|
||||
navigation: 'پیمایش',
|
||||
options: 'گزینهها',
|
||||
position: 'موقعیت',
|
||||
refreshFields: 'بهروزرسانی فیلدها',
|
||||
pageNumber: 'شماره صفحه',
|
||||
currentPage: 'صفحه فعلی',
|
||||
totalPages: 'تعداد کل صفحات',
|
||||
dateTime: 'تاریخ و زمان',
|
||||
updateAutomatically: 'بهروزرسانی خودکار',
|
||||
insertFailed: 'سند تغییر کرده یا قابل ویرایش نیست. این پنجره را ببندید و دوباره تلاش کنید.',
|
||||
goToHeader: 'رفتن به سرصفحه',
|
||||
goToFooter: 'رفتن به پاصفحه',
|
||||
|
||||
linkToPrevious: 'پیوند به قبلی',
|
||||
header: 'هدر',
|
||||
footer: 'فوتر',
|
||||
|
||||
@@ -118,6 +118,19 @@ const locale: typeof enUS = {
|
||||
deleteTable: 'Supprimer le tableau',
|
||||
},
|
||||
headerFooter: {
|
||||
navigation: 'Navigation',
|
||||
options: 'Options',
|
||||
position: 'Position',
|
||||
refreshFields: 'Mettre à jour les champs',
|
||||
pageNumber: 'Numéro de page',
|
||||
currentPage: 'Page actuelle',
|
||||
totalPages: 'Nombre total de pages',
|
||||
dateTime: 'Date et heure',
|
||||
updateAutomatically: 'Mettre à jour automatiquement',
|
||||
insertFailed: 'Le document a changé ou ne peut pas être modifié. Fermez cette boîte de dialogue et réessayez.',
|
||||
goToHeader: 'Aller à l’en-tête',
|
||||
goToFooter: 'Aller au pied de page',
|
||||
|
||||
linkToPrevious: 'Lier au précédent',
|
||||
header: 'En-tête',
|
||||
footer: 'Pied de page',
|
||||
|
||||
@@ -118,6 +118,19 @@ const locale: typeof enUS = {
|
||||
deleteTable: 'Hapus tabel',
|
||||
},
|
||||
headerFooter: {
|
||||
navigation: 'Navigasi',
|
||||
options: 'Opsi',
|
||||
position: 'Posisi',
|
||||
refreshFields: 'Perbarui bidang',
|
||||
pageNumber: 'Nomor halaman',
|
||||
currentPage: 'Halaman saat ini',
|
||||
totalPages: 'Total halaman',
|
||||
dateTime: 'Tanggal dan waktu',
|
||||
updateAutomatically: 'Perbarui otomatis',
|
||||
insertFailed: 'Dokumen berubah atau tidak dapat diedit. Tutup dialog ini dan coba lagi.',
|
||||
goToHeader: 'Ke header',
|
||||
goToFooter: 'Ke footer',
|
||||
|
||||
linkToPrevious: 'Tautkan ke sebelumnya',
|
||||
header: 'Header',
|
||||
footer: 'Footer',
|
||||
|
||||
@@ -118,6 +118,19 @@ const locale: typeof enUS = {
|
||||
deleteTable: 'Elimina tabella',
|
||||
},
|
||||
headerFooter: {
|
||||
navigation: 'Navigazione',
|
||||
options: 'Opzioni',
|
||||
position: 'Posizione',
|
||||
refreshFields: 'Aggiorna campi',
|
||||
pageNumber: 'Numero di pagina',
|
||||
currentPage: 'Pagina corrente',
|
||||
totalPages: 'Totale pagine',
|
||||
dateTime: 'Data e ora',
|
||||
updateAutomatically: 'Aggiorna automaticamente',
|
||||
insertFailed: 'Il documento è cambiato o non può essere modificato. Chiudi questa finestra e riprova.',
|
||||
goToHeader: 'Vai all’intestazione',
|
||||
goToFooter: 'Vai al piè di pagina',
|
||||
|
||||
linkToPrevious: 'Collega al precedente',
|
||||
header: 'Intestazione',
|
||||
footer: 'Piè di pagina',
|
||||
|
||||
@@ -118,6 +118,19 @@ const locale: typeof enUS = {
|
||||
deleteTable: '表を削除',
|
||||
},
|
||||
headerFooter: {
|
||||
navigation: '移動',
|
||||
options: 'オプション',
|
||||
position: '位置',
|
||||
refreshFields: 'フィールドを更新',
|
||||
pageNumber: 'ページ番号',
|
||||
currentPage: '現在のページ',
|
||||
totalPages: '総ページ数',
|
||||
dateTime: '日付と時刻',
|
||||
updateAutomatically: '自動更新',
|
||||
insertFailed: '文書が変更されたか編集できません。ダイアログを閉じて再試行してください。',
|
||||
goToHeader: 'ヘッダーに移動',
|
||||
goToFooter: 'フッターに移動',
|
||||
|
||||
linkToPrevious: '前と同じ',
|
||||
header: 'ヘッダー',
|
||||
footer: 'フッター',
|
||||
|
||||
@@ -118,6 +118,19 @@ const locale: typeof enUS = {
|
||||
deleteTable: '표 삭제',
|
||||
},
|
||||
headerFooter: {
|
||||
navigation: '탐색',
|
||||
options: '옵션',
|
||||
position: '위치',
|
||||
refreshFields: '필드 업데이트',
|
||||
pageNumber: '페이지 번호',
|
||||
currentPage: '현재 페이지',
|
||||
totalPages: '전체 페이지 수',
|
||||
dateTime: '날짜 및 시간',
|
||||
updateAutomatically: '자동 업데이트',
|
||||
insertFailed: '문서가 변경되었거나 편집할 수 없습니다. 대화 상자를 닫고 다시 시도하세요.',
|
||||
goToHeader: '머리글로 이동',
|
||||
goToFooter: '바닥글로 이동',
|
||||
|
||||
linkToPrevious: '이전 항목에 연결',
|
||||
header: '머리글',
|
||||
footer: '바닥글',
|
||||
|
||||
@@ -118,6 +118,19 @@ const locale: typeof enUS = {
|
||||
deleteTable: 'Usuń tabelę',
|
||||
},
|
||||
headerFooter: {
|
||||
navigation: 'Nawigacja',
|
||||
options: 'Opcje',
|
||||
position: 'Pozycja',
|
||||
refreshFields: 'Aktualizuj pola',
|
||||
pageNumber: 'Numer strony',
|
||||
currentPage: 'Bieżąca strona',
|
||||
totalPages: 'Liczba stron',
|
||||
dateTime: 'Data i godzina',
|
||||
updateAutomatically: 'Aktualizuj automatycznie',
|
||||
insertFailed: 'Dokument zmienił się lub nie można go edytować. Zamknij okno i spróbuj ponownie.',
|
||||
goToHeader: 'Przejdź do nagłówka',
|
||||
goToFooter: 'Przejdź do stopki',
|
||||
|
||||
linkToPrevious: 'Połącz z poprzednim',
|
||||
header: 'Nagłówek',
|
||||
footer: 'Stopka',
|
||||
|
||||
@@ -118,6 +118,19 @@ const locale: typeof enUS = {
|
||||
deleteTable: 'Excluir tabela',
|
||||
},
|
||||
headerFooter: {
|
||||
navigation: 'Navegação',
|
||||
options: 'Opções',
|
||||
position: 'Posição',
|
||||
refreshFields: 'Atualizar campos',
|
||||
pageNumber: 'Número da página',
|
||||
currentPage: 'Página atual',
|
||||
totalPages: 'Total de páginas',
|
||||
dateTime: 'Data e hora',
|
||||
updateAutomatically: 'Atualizar automaticamente',
|
||||
insertFailed: 'O documento mudou ou não pode ser editado. Feche esta caixa de diálogo e tente novamente.',
|
||||
goToHeader: 'Ir para o cabeçalho',
|
||||
goToFooter: 'Ir para o rodapé',
|
||||
|
||||
linkToPrevious: 'Vincular ao anterior',
|
||||
header: 'Cabeçalho',
|
||||
footer: 'Rodapé',
|
||||
|
||||
@@ -118,6 +118,19 @@ const locale: typeof enUS = {
|
||||
deleteTable: 'Удалить таблицу',
|
||||
},
|
||||
headerFooter: {
|
||||
navigation: 'Навигация',
|
||||
options: 'Параметры',
|
||||
position: 'Положение',
|
||||
refreshFields: 'Обновить поля',
|
||||
pageNumber: 'Номер страницы',
|
||||
currentPage: 'Текущая страница',
|
||||
totalPages: 'Всего страниц',
|
||||
dateTime: 'Дата и время',
|
||||
updateAutomatically: 'Обновлять автоматически',
|
||||
insertFailed: 'Документ изменён или недоступен для редактирования. Закройте диалог и повторите попытку.',
|
||||
goToHeader: 'К верхнему колонтитулу',
|
||||
goToFooter: 'К нижнему колонтитулу',
|
||||
|
||||
linkToPrevious: 'Связать с предыдущим',
|
||||
header: 'Верхний колонтитул',
|
||||
footer: 'Нижний колонтитул',
|
||||
|
||||
@@ -118,6 +118,19 @@ const locale: typeof enUS = {
|
||||
deleteTable: 'Odstrániť tabuľku',
|
||||
},
|
||||
headerFooter: {
|
||||
navigation: 'Navigácia',
|
||||
options: 'Možnosti',
|
||||
position: 'Poloha',
|
||||
refreshFields: 'Aktualizovať polia',
|
||||
pageNumber: 'Číslo strany',
|
||||
currentPage: 'Aktuálna strana',
|
||||
totalPages: 'Celkový počet strán',
|
||||
dateTime: 'Dátum a čas',
|
||||
updateAutomatically: 'Aktualizovať automaticky',
|
||||
insertFailed: 'Dokument sa zmenil alebo ho nemožno upraviť. Zatvorte dialóg a skúste to znova.',
|
||||
goToHeader: 'Prejsť na hlavičku',
|
||||
goToFooter: 'Prejsť na pätu',
|
||||
|
||||
linkToPrevious: 'Prepojiť s predchádzajúcim',
|
||||
header: 'Hlavička',
|
||||
footer: 'Päta',
|
||||
|
||||
@@ -118,6 +118,19 @@ const locale: typeof enUS = {
|
||||
deleteTable: 'Xóa bảng',
|
||||
},
|
||||
headerFooter: {
|
||||
navigation: 'Điều hướng',
|
||||
options: 'Tùy chọn',
|
||||
position: 'Vị trí',
|
||||
refreshFields: 'Cập nhật trường',
|
||||
pageNumber: 'Số trang',
|
||||
currentPage: 'Trang hiện tại',
|
||||
totalPages: 'Tổng số trang',
|
||||
dateTime: 'Ngày và giờ',
|
||||
updateAutomatically: 'Tự động cập nhật',
|
||||
insertFailed: 'Tài liệu đã thay đổi hoặc không thể chỉnh sửa. Đóng hộp thoại và thử lại.',
|
||||
goToHeader: 'Đi đến đầu trang',
|
||||
goToFooter: 'Đi đến chân trang',
|
||||
|
||||
linkToPrevious: 'Liên kết với phần trước',
|
||||
header: 'Đầu trang',
|
||||
footer: 'Chân trang',
|
||||
|
||||
@@ -118,6 +118,19 @@ const locale: typeof enUS = {
|
||||
deleteTable: '删除表格',
|
||||
},
|
||||
headerFooter: {
|
||||
navigation: '导航',
|
||||
options: '选项',
|
||||
position: '位置',
|
||||
refreshFields: '更新域',
|
||||
pageNumber: '页码',
|
||||
currentPage: '当前页码',
|
||||
totalPages: '总页数',
|
||||
dateTime: '日期和时间',
|
||||
updateAutomatically: '自动更新',
|
||||
insertFailed: '文档已发生变化或不可编辑,请关闭对话框后重试。',
|
||||
goToHeader: '转到页眉',
|
||||
goToFooter: '转到页脚',
|
||||
|
||||
linkToPrevious: '链接到上一节',
|
||||
header: '页眉',
|
||||
footer: '页脚',
|
||||
|
||||
@@ -118,6 +118,19 @@ const locale: typeof enUS = {
|
||||
deleteTable: '刪除表格',
|
||||
},
|
||||
headerFooter: {
|
||||
navigation: '導覽',
|
||||
options: '選項',
|
||||
position: '位置',
|
||||
refreshFields: '更新欄位',
|
||||
pageNumber: '頁碼',
|
||||
currentPage: '目前頁碼',
|
||||
totalPages: '總頁數',
|
||||
dateTime: '日期與時間',
|
||||
updateAutomatically: '自動更新',
|
||||
insertFailed: '文件已變更或無法編輯,請關閉對話框後重試。',
|
||||
goToHeader: '移至頁首',
|
||||
goToFooter: '移至頁尾',
|
||||
|
||||
linkToPrevious: '連結到上一節',
|
||||
header: '頁眉',
|
||||
footer: '頁腳',
|
||||
|
||||
@@ -118,6 +118,19 @@ const locale: typeof enUS = {
|
||||
deleteTable: '刪除表格',
|
||||
},
|
||||
headerFooter: {
|
||||
navigation: '導覽',
|
||||
options: '選項',
|
||||
position: '位置',
|
||||
refreshFields: '更新欄位',
|
||||
pageNumber: '頁碼',
|
||||
currentPage: '目前頁碼',
|
||||
totalPages: '總頁數',
|
||||
dateTime: '日期與時間',
|
||||
updateAutomatically: '自動更新',
|
||||
insertFailed: '文件已變更或無法編輯,請關閉對話方塊後重試。',
|
||||
goToHeader: '移至頁首',
|
||||
goToFooter: '移至頁尾',
|
||||
|
||||
linkToPrevious: '連結到上一節',
|
||||
header: '頁眉',
|
||||
footer: '頁腳',
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* 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 type { MenuSchemaType } from '@univerjs/ui';
|
||||
import { CustomRangeType, IUniverInstanceService, UniverInstanceType } from '@univerjs/core';
|
||||
import { DocSelectionManagerService } from '@univerjs/docs';
|
||||
import { UnitAction } from '@univerjs/protocol';
|
||||
import { ContextMenuGroup, ContextMenuPosition, MenuItemType, MenuManagerPosition } from '@univerjs/ui';
|
||||
import { combineLatest, map, startWith } from 'rxjs';
|
||||
import { CloseHeaderFooterCommand } from '../commands/commands/doc-header-footer.command';
|
||||
import { UpdateSelectedDocFieldsCommand } from '../commands/commands/refresh-header-footer-fields.command';
|
||||
import {
|
||||
DOC_HEADER_FOOTER_RIBBON_CONTROL,
|
||||
DOC_HEADER_FOOTER_RIBBON_TAB,
|
||||
} from '../views/header-footer/panel/component-name';
|
||||
import { disableMenuWithoutDocumentUnitPermission } from './menu';
|
||||
|
||||
export const headerFooterRibbonSchema: MenuSchemaType = {
|
||||
[ContextMenuPosition.MAIN_AREA]: {
|
||||
[ContextMenuGroup.FORMAT]: {
|
||||
[UpdateSelectedDocFieldsCommand.id]: {
|
||||
order: 1,
|
||||
menuItemFactory: (accessor) => {
|
||||
const instances = accessor.get(IUniverInstanceService);
|
||||
const selections = accessor.get(DocSelectionManagerService);
|
||||
return {
|
||||
id: UpdateSelectedDocFieldsCommand.id,
|
||||
type: MenuItemType.BUTTON,
|
||||
title: 'docs-ui.headerFooter.refreshFields',
|
||||
disabled$: disableMenuWithoutDocumentUnitPermission(accessor, UnitAction.Edit),
|
||||
hidden$: combineLatest([
|
||||
instances.getCurrentTypeOfUnit$<DocumentDataModel>(UniverInstanceType.UNIVER_DOC),
|
||||
selections.textSelection$.pipe(startWith(null)),
|
||||
]).pipe(map(([model]) => !model || !(selections.getTextRanges({ unitId: model.getUnitId(), subUnitId: model.getUnitId() }) ?? [])
|
||||
.some((selection) => model.getSelfOrHeaderFooterModel(selection.segmentId)?.getBody()?.customRanges?.some((field) =>
|
||||
field.rangeType === CustomRangeType.FIELD && selection.startOffset <= field.endIndex && selection.endOffset > field.startIndex)))),
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
[MenuManagerPosition.RIBBON]: {
|
||||
[DOC_HEADER_FOOTER_RIBBON_TAB]: {
|
||||
order: 90,
|
||||
title: 'docs-ui.toolbar.headerFooter',
|
||||
contextual: true,
|
||||
...Object.fromEntries(['insertion', 'navigation', 'options', 'position'].map((kind, order) => [
|
||||
`${DOC_HEADER_FOOTER_RIBBON_TAB}.${kind}`,
|
||||
{
|
||||
order,
|
||||
[`${DOC_HEADER_FOOTER_RIBBON_CONTROL}.${kind}`]: {
|
||||
order: 0,
|
||||
gridLayout: { row: 1, column: 1, rowSpan: 2 },
|
||||
menuItemFactory: () => ({
|
||||
id: `${DOC_HEADER_FOOTER_RIBBON_CONTROL}.${kind}`,
|
||||
type: MenuItemType.BUTTON,
|
||||
label: { name: DOC_HEADER_FOOTER_RIBBON_CONTROL, hoverable: false, props: { kind } },
|
||||
}),
|
||||
},
|
||||
},
|
||||
])),
|
||||
'docs-ui.headerFooter.close': {
|
||||
order: 5,
|
||||
[CloseHeaderFooterCommand.id]: {
|
||||
order: 0,
|
||||
gridLayout: { row: 1, column: 1, rowSpan: 2, showLabel: true },
|
||||
menuItemFactory: () => ({
|
||||
id: CloseHeaderFooterCommand.id,
|
||||
type: MenuItemType.BUTTON,
|
||||
icon: 'CloseIcon',
|
||||
title: 'docs-ui.headerFooter.closeHeaderFooter',
|
||||
tooltip: 'docs-ui.headerFooter.closeHeaderFooter',
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -37,7 +37,6 @@ import {
|
||||
} from '@univerjs/docs';
|
||||
import { IRenderManagerService, UniverRenderEnginePlugin } from '@univerjs/engine-render';
|
||||
import { IShortcutService } from '@univerjs/ui';
|
||||
|
||||
import pkg from '../package.json';
|
||||
import { AfterSpaceCommand, EnterCommand, TabCommand } from './commands/commands/auto-format.command';
|
||||
import { BreakLineCommand } from './commands/commands/break-line.command';
|
||||
@@ -154,6 +153,7 @@ import { SetDocZoomRatioOperation } from './commands/operations/set-doc-zoom-rat
|
||||
import { defaultPluginConfig, DOCS_UI_PLUGIN_CONFIG_KEY, DOCS_UI_PLUGIN_NAME } from './config/config';
|
||||
import { ComponentsController } from './controllers/components.controller';
|
||||
import { DocAutoFormatController } from './controllers/doc-auto-format.controller';
|
||||
import { DocHeaderFooterRibbonController } from './controllers/doc-header-footer-ribbon.controller';
|
||||
import { DocHeaderFooterController } from './controllers/doc-header-footer.controller';
|
||||
import { DocMoveCursorController } from './controllers/doc-move-cursor.controller';
|
||||
import { DocParagraphSettingController } from './controllers/doc-paragraph-setting.controller';
|
||||
@@ -168,6 +168,7 @@ import { DocChecklistRenderController } from './controllers/render-controllers/d
|
||||
import { DocClipboardController } from './controllers/render-controllers/doc-clipboard.controller';
|
||||
import { DocContextMenuRenderController } from './controllers/render-controllers/doc-contextmenu.render-controller';
|
||||
import { DocEditorBridgeController } from './controllers/render-controllers/doc-editor-bridge.controller';
|
||||
import { DocFieldRenderController } from './controllers/render-controllers/doc-field.render-controller';
|
||||
import { DocIMEInputController } from './controllers/render-controllers/doc-ime-input.controller';
|
||||
import { DocInputController } from './controllers/render-controllers/doc-input.controller';
|
||||
import {
|
||||
@@ -536,10 +537,12 @@ export class UniverDocsUIPlugin extends Plugin {
|
||||
private _initRenderModules() {
|
||||
([
|
||||
[DocEventManagerService],
|
||||
[DocFieldRenderController],
|
||||
[DocFloatMenuService],
|
||||
[DocPasteOptionsRenderController],
|
||||
[DocParagraphMenuService],
|
||||
[DocHeaderFooterController],
|
||||
[DocHeaderFooterRibbonController],
|
||||
[DocResizeRenderController],
|
||||
[DocParagraphPlaceholderRenderController],
|
||||
[DocContextMenuRenderController],
|
||||
|
||||
@@ -515,8 +515,14 @@ export class DocEventManagerService extends Disposable implements IRenderModule
|
||||
|
||||
private _initPointer() {
|
||||
let preCursor = CURSOR_TYPE.TEXT;
|
||||
let pointing = false;
|
||||
this.disposeWithMe(this.hoverCustomRanges$.subscribe((ranges) => {
|
||||
if (ranges.length) {
|
||||
const nextPointing = ranges.some(({ range }) => range.rangeType !== CustomRangeType.FIELD);
|
||||
if (nextPointing === pointing) {
|
||||
return;
|
||||
}
|
||||
pointing = nextPointing;
|
||||
if (pointing) {
|
||||
preCursor = this._context.scene.getCursor();
|
||||
this._context.scene.setCursor(CURSOR_TYPE.POINTER);
|
||||
} else {
|
||||
@@ -663,6 +669,12 @@ export class DocEventManagerService extends Disposable implements IRenderModule
|
||||
return [...this._bulletBounds];
|
||||
}
|
||||
|
||||
getCustomRangeBounds(): ICustomRangeBound[] {
|
||||
this._buildCustomRangeBounds();
|
||||
|
||||
return [...this._customRangeBounds];
|
||||
}
|
||||
|
||||
getListContextMenuBulletByOffset(offsetX: number, offsetY: number): Nullable<IBulletBound> {
|
||||
const point = transformOffset2Bound(offsetX, offsetY, this._context.scene);
|
||||
|
||||
|
||||
@@ -95,8 +95,8 @@ export class DocPrintInterceptorService extends Disposable {
|
||||
}
|
||||
|
||||
collectPageDrawingIds(page, drawingIds);
|
||||
const header = data.skeHeaders.get(page.headerId)?.get(page.pageWidth);
|
||||
const footer = data.skeFooters.get(page.footerId)?.get(page.pageWidth);
|
||||
const header = data.skeHeaders.get(page.headerId)?.get(page.headerLayoutKey ?? page.pageWidth);
|
||||
const footer = data.skeFooters.get(page.footerId)?.get(page.footerLayoutKey ?? page.pageWidth);
|
||||
if (header) {
|
||||
collectPageDrawingIds(header, drawingIds);
|
||||
}
|
||||
|
||||
+38
-8
@@ -31,8 +31,14 @@ import {
|
||||
UniverInstanceType,
|
||||
} from '@univerjs/core';
|
||||
import { DocLayoutExecutorService, DocSelectionManagerService, DocSkeletonManagerService } from '@univerjs/docs';
|
||||
import { DeviceType, GlyphType, NORMAL_TEXT_SELECTION_PLUGIN_STYLE, PointerInput, RenderUnit } from '@univerjs/engine-render';
|
||||
import { ILayoutService } from '@univerjs/ui';
|
||||
import {
|
||||
DeviceType,
|
||||
GlyphType,
|
||||
NORMAL_TEXT_SELECTION_PLUGIN_STYLE,
|
||||
PointerInput,
|
||||
RenderUnit,
|
||||
} from '@univerjs/engine-render';
|
||||
import { DesktopLayoutService, ILayoutService } from '@univerjs/ui';
|
||||
import { Subject } from 'rxjs';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
@@ -735,8 +741,30 @@ describe('doc selection render service internals', () => {
|
||||
expect(service._rangeList).toEqual([cursorRange]);
|
||||
});
|
||||
|
||||
it('keeps the rendered caret when its replacement page is not resolved yet', () => {
|
||||
const { service } = createService();
|
||||
it('keeps the rendered caret when its replacement page is not resolved yet', ({ onTestFinished }) => {
|
||||
const univer = new Univer();
|
||||
const injector = univer.__getInjector();
|
||||
injector.add([DocSelectionManagerService]);
|
||||
injector.add([DocLayoutExecutorService]);
|
||||
injector.add([ILayoutService, { useClass: DesktopLayoutService }]);
|
||||
const doc = univer.createUnit(UniverInstanceType.UNIVER_DOC, {
|
||||
id: 'unresolved-caret',
|
||||
body: { dataStream: 'Caret\r\n', paragraphs: [{ paragraphId: 'p', startIndex: 5 }] },
|
||||
});
|
||||
const render = injector.createInstance(RenderUnit, {
|
||||
engine: {} as never,
|
||||
scene: { getViewports: () => [], getEngine: () => null, enableObjectsEvent: () => {} } as never,
|
||||
isMainScene: true,
|
||||
unit: doc,
|
||||
});
|
||||
render.addRenderDependencies([[DocSkeletonManagerService], [DocSelectionRenderService]]);
|
||||
onTestFinished(() => {
|
||||
render.dispose();
|
||||
univer.dispose();
|
||||
});
|
||||
const subject = render.with(DocSelectionRenderService);
|
||||
const service = subject as unknown as IServiceHarness;
|
||||
const publish = vi.spyOn(service._textSelectionInner$, 'next');
|
||||
const currentCaret = createTextRange({
|
||||
collapsed: true,
|
||||
startOffset: 34,
|
||||
@@ -758,7 +786,7 @@ describe('doc selection render service internals', () => {
|
||||
expect(service._rangeList).toEqual([currentCaret]);
|
||||
expect(currentCaret.dispose).not.toHaveBeenCalled();
|
||||
expect(unresolvedCaret.dispose).toHaveBeenCalledTimes(1);
|
||||
expect(service._textSelectionInner$.next).not.toHaveBeenCalled();
|
||||
expect(publish).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deactivates structural carets when document ranges contain a visible selection', () => {
|
||||
@@ -940,15 +968,16 @@ describe('doc selection render service internals', () => {
|
||||
});
|
||||
|
||||
describe('DocSelectionRenderService', () => {
|
||||
it.each(['', 'note'])('uses the logical caret in segment "%s" while replacement layout is pending', (segmentId) => {
|
||||
TestLayoutService.reset();
|
||||
it.each(['', 'note', 'header', 'footer'])('uses the logical caret in segment "%s" while replacement layout is pending', (segmentId) => {
|
||||
const univer = new Univer();
|
||||
const injector = univer.__getInjector();
|
||||
injector.add([DocSelectionManagerService]);
|
||||
injector.add([DocLayoutExecutorService]);
|
||||
injector.add([ILayoutService, { useClass: TestLayoutService as never }]);
|
||||
injector.add([ILayoutService, { useClass: DesktopLayoutService }]);
|
||||
const doc = univer.createUnit(UniverInstanceType.UNIVER_DOC, {
|
||||
id: 'pending-input',
|
||||
headers: { header: { headerId: 'header', body: { dataStream: 'Header\r\n', paragraphs: [{ paragraphId: 'hp', startIndex: 6 }] } } },
|
||||
footers: { footer: { footerId: 'footer', body: { dataStream: 'Footer\r\n', paragraphs: [{ paragraphId: 'fp', startIndex: 6 }] } } },
|
||||
documentStyle: { documentFlavor: DocumentFlavor.TRADITIONAL },
|
||||
body: { dataStream: 'Body\r\n', paragraphs: [{ paragraphId: 'p', startIndex: 4 }] },
|
||||
notes: { note: { type: 'footnote' as const, noteId: 'note', body: {
|
||||
@@ -965,6 +994,7 @@ describe('DocSelectionRenderService', () => {
|
||||
render.addRenderDependencies([[DocSkeletonManagerService], [DocSelectionRenderService]]);
|
||||
try {
|
||||
const service = render.with(DocSelectionRenderService);
|
||||
service.setSegment(segmentId);
|
||||
render.with(DocSkeletonManagerService).getSkeleton().beginExternalLayout({ reason: 'edit' });
|
||||
const manager = injector.get(DocSelectionManagerService);
|
||||
manager.__TEST_ONLY_setCurrentSelection({ unitId: 'pending-input', subUnitId: 'pending-input' });
|
||||
|
||||
@@ -15,8 +15,24 @@
|
||||
*/
|
||||
|
||||
import type { ITable, Nullable } from '@univerjs/core';
|
||||
import type { DocumentSkeleton, IDocsTableRenderViewport, IDocumentOffsetConfig, IDocumentSkeletonPage, IDocumentSkeletonRow, IDocumentSkeletonTable, INodePosition, IPoint } from '@univerjs/engine-render';
|
||||
import { DocumentSkeletonPageType, documentSkeletonTableIterator, getDocsTableRenderViewport, getPageFromPath, getTableIdAndSliceIndex, Liquid } from '@univerjs/engine-render';
|
||||
import type {
|
||||
DocumentSkeleton,
|
||||
IDocsTableRenderViewport,
|
||||
IDocumentOffsetConfig,
|
||||
IDocumentSkeletonPage,
|
||||
IDocumentSkeletonRow,
|
||||
IDocumentSkeletonTable,
|
||||
INodePosition,
|
||||
IPoint,
|
||||
} from '@univerjs/engine-render';
|
||||
import {
|
||||
DocumentSkeletonPageType,
|
||||
documentSkeletonTableIterator,
|
||||
getDocsTableRenderViewport,
|
||||
getPageFromPath,
|
||||
getTableIdAndSliceIndex,
|
||||
Liquid,
|
||||
} from '@univerjs/engine-render';
|
||||
import { compareNodePositionLogic, pushToPoints } from './convert-text-range';
|
||||
|
||||
// The anchor and focus need to be in the same table,
|
||||
@@ -681,8 +697,8 @@ function getCellPageFromPositionPath(
|
||||
|
||||
const { headerId, footerId, pageWidth } = rootPage;
|
||||
const segmentPages = [
|
||||
headerId == null ? null : skeletonData.skeHeaders.get(headerId)?.get(pageWidth),
|
||||
footerId == null ? null : skeletonData.skeFooters.get(footerId)?.get(pageWidth),
|
||||
headerId == null ? null : skeletonData.skeHeaders.get(headerId)?.get(rootPage.headerLayoutKey ?? pageWidth),
|
||||
footerId == null ? null : skeletonData.skeFooters.get(footerId)?.get(rootPage.footerLayoutKey ?? pageWidth),
|
||||
];
|
||||
|
||||
for (const segmentPage of segmentPages) {
|
||||
|
||||
@@ -675,9 +675,9 @@ export class NodePositionConvertToCursor {
|
||||
let segmentPage: Nullable<IDocumentSkeletonPage> = page;
|
||||
|
||||
if (pageType === DocumentSkeletonPageType.HEADER) {
|
||||
segmentPage = skeHeaders.get(headerId)?.get(pageWidth);
|
||||
segmentPage = skeHeaders.get(headerId)?.get(page.headerLayoutKey ?? pageWidth);
|
||||
} else if (pageType === DocumentSkeletonPageType.FOOTER) {
|
||||
segmentPage = skeFooters.get(footerId)?.get(pageWidth);
|
||||
segmentPage = skeFooters.get(footerId)?.get(page.footerLayoutKey ?? pageWidth);
|
||||
} else if (pageType === DocumentSkeletonPageType.NOTE) {
|
||||
const noteId = getPageFromPath(skeletonData, path)?.segmentId;
|
||||
segmentPage = page.notes?.find((note) => note.noteId === noteId)?.page;
|
||||
@@ -937,8 +937,8 @@ function getCellPageFromSegmentPath(
|
||||
|
||||
const segmentPages: IDocumentSkeletonPage[] = [];
|
||||
const { headerId, footerId, pageWidth } = rootPage;
|
||||
const headerPage = headerId == null ? null : skeletonData.skeHeaders.get(headerId)?.get(pageWidth);
|
||||
const footerPage = footerId == null ? null : skeletonData.skeFooters.get(footerId)?.get(pageWidth);
|
||||
const headerPage = headerId == null ? null : skeletonData.skeHeaders.get(headerId)?.get(rootPage.headerLayoutKey ?? pageWidth);
|
||||
const footerPage = footerId == null ? null : skeletonData.skeFooters.get(footerId)?.get(rootPage.footerLayoutKey ?? pageWidth);
|
||||
|
||||
if (headerPage != null) {
|
||||
segmentPages.push(headerPage);
|
||||
|
||||
@@ -296,10 +296,8 @@ export class DocSelectionRenderService extends RxDisposable implements IRenderMo
|
||||
return false;
|
||||
}
|
||||
this._syncNoteSegment(ranges[0]);
|
||||
const isBodyOrNote = this._currentSegmentId === '' ||
|
||||
this._context.unit.getSnapshot().notes?.[this._currentSegmentId] != null;
|
||||
let currentLogicalSelection: Nullable<IDocSelectionInnerParam> = null;
|
||||
if (isBodyOrNote && ranges.length > 0) {
|
||||
if (ranges.length > 0) {
|
||||
const { unitId } = this._context;
|
||||
const selection = this._docSelectionManagerService.getSelectionInfo({ unitId, subUnitId: unitId });
|
||||
// Only a refresh of the current logical selection belongs to this
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
import type { IShortcutItem } from '@univerjs/ui';
|
||||
import { KeyCode, MetaKeys } from '@univerjs/ui';
|
||||
import { TabCommand } from '../commands/commands/auto-format.command';
|
||||
import { UpdateSelectedDocFieldsCommand } from '../commands/commands/refresh-header-footer-fields.command';
|
||||
import { whenDocAndEditorFocused } from './utils';
|
||||
|
||||
export const TabShortCut: IShortcutItem = {
|
||||
@@ -25,6 +26,12 @@ export const TabShortCut: IShortcutItem = {
|
||||
preconditions: whenDocAndEditorFocused,
|
||||
};
|
||||
|
||||
export const UpdateDocFieldsShortcut: IShortcutItem = {
|
||||
id: UpdateSelectedDocFieldsCommand.id,
|
||||
binding: KeyCode.F9,
|
||||
preconditions: whenDocAndEditorFocused,
|
||||
};
|
||||
|
||||
export const ShiftTabShortCut: IShortcutItem = {
|
||||
id: TabCommand.id,
|
||||
binding: KeyCode.TAB | MetaKeys.SHIFT,
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export type DateTimeFieldFormat = 'short' | 'long' | 'time';
|
||||
|
||||
export function createDateTimeField(format: DateTimeFieldFormat, locale: string, date: Date) {
|
||||
const options: Intl.DateTimeFormatOptions = format === 'time'
|
||||
? { hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23' }
|
||||
: { year: 'numeric', month: format === 'long' ? 'long' : '2-digit', day: '2-digit' };
|
||||
const formatter = new Intl.DateTimeFormat(locale, { ...options, calendar: 'gregory', numberingSystem: 'latn' });
|
||||
const parts = formatter.formatToParts(date);
|
||||
const tokens: Record<string, string> = {
|
||||
year: 'yyyy',
|
||||
month: format === 'long' ? 'MMMM' : 'MM',
|
||||
day: 'dd',
|
||||
hour: 'HH',
|
||||
minute: 'mm',
|
||||
second: 'ss',
|
||||
};
|
||||
const mask = parts.map((part) => tokens[part.type] ?? `'${part.value.replace(/'/g, "''")}'`).join('');
|
||||
const fieldType = format === 'time' ? 'TIME' : 'DATE';
|
||||
return { fieldType, instruction: `${fieldType} \\@ "${mask}"`, cachedResult: formatter.format(date) };
|
||||
}
|
||||
|
||||
export function resolveDateTimeField(instruction: string, locale: string, date: Date): string | undefined {
|
||||
try {
|
||||
Intl.getCanonicalLocales(locale);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
for (const format of ['short', 'long', 'time'] as const) {
|
||||
const field = createDateTimeField(format, locale, date);
|
||||
if (field.instruction === instruction) {
|
||||
return field.cachedResult;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -72,8 +72,8 @@ export class HeaderFooterContentCover extends BaseObject {
|
||||
if (!page.isLayoutPlaceholder && !page.isMaterializationPlaceholder
|
||||
&& !this._documents.isSkipByDiffBounds(page, pageTop, pageLeft, viewport)) {
|
||||
const pageBounds = this._transformBounds({ left: pageLeft, top: pageTop, right: pageLeft + page.pageWidth, bottom: pageTop + page.pageHeight });
|
||||
const header = data.skeHeaders.get(page.headerId)?.get(page.pageWidth);
|
||||
const footer = data.skeFooters.get(page.footerId)?.get(page.pageWidth);
|
||||
const header = data.skeHeaders.get(page.headerId)?.get(page.headerLayoutKey ?? page.pageWidth);
|
||||
const footer = data.skeFooters.get(page.footerId)?.get(page.footerLayoutKey ?? page.pageWidth);
|
||||
const local: IBoundRectNoAngle[] = [];
|
||||
if (header) {
|
||||
this._collectTextAndTables(header, pageLeft + page.marginLeft, pageTop + header.marginTop, local);
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* 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 type { IInsertHeaderFooterFieldParams } from '../../../commands/commands/insert-header-footer-field.command';
|
||||
import type { LocaleKey } from '../../../locale/types';
|
||||
import type { DateTimeFieldFormat } from '../../../utils/date-time-field';
|
||||
import { ICommandService, IUniverInstanceService, LOCALE_META, LocaleService } from '@univerjs/core';
|
||||
import { Button, Checkbox, Dialog, DropdownMenu, selectClassName } from '@univerjs/design';
|
||||
import { DocSelectionManagerService } from '@univerjs/docs';
|
||||
import { CalendarIcon, MoreDownIcon, NumberIcon } from '@univerjs/icons';
|
||||
import { ILayoutService, IShortcutService, useDependency, useObservable } from '@univerjs/ui';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { InsertHeaderFooterFieldCommand } from '../../../commands/commands/insert-header-footer-field.command';
|
||||
import { RefreshHeaderFooterFieldsCommand } from '../../../commands/commands/refresh-header-footer-fields.command';
|
||||
import { createDateTimeField } from '../../../utils/date-time-field';
|
||||
|
||||
export function DocHeaderFooterFields({ unitId, disabled }: { unitId: string; disabled: boolean }) {
|
||||
const localeService = useDependency(LocaleService);
|
||||
useObservable(localeService.currentLocale$);
|
||||
const commands = useDependency(ICommandService);
|
||||
const layout = useDependency(ILayoutService);
|
||||
const shortcuts = useDependency(IShortcutService);
|
||||
const selections = useDependency(DocSelectionManagerService);
|
||||
const instances = useDependency(IUniverInstanceService);
|
||||
const [target, setTarget] = useState<Omit<IInsertHeaderFooterFieldParams, 'fieldType'> | null>(null);
|
||||
const [format, setFormat] = useState<DateTimeFieldFormat>('short');
|
||||
const [automatic, setAutomatic] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
const [previewDate, setPreviewDate] = useState(() => new Date());
|
||||
const locale = LOCALE_META[localeService.getCurrentLocale()].tag;
|
||||
|
||||
const dialogOpen = target !== null;
|
||||
const dialogFocusRef = useRef<{ unitId: string; focusedUnitId: string | undefined } | null>(null);
|
||||
useEffect(() => {
|
||||
if (dialogOpen) {
|
||||
return;
|
||||
}
|
||||
const focus = dialogFocusRef.current;
|
||||
dialogFocusRef.current = null;
|
||||
if (focus?.unitId === unitId && instances.getUnit(unitId) &&
|
||||
instances.getFocusedUnit()?.getUnitId() === focus.focusedUnitId) {
|
||||
layout.focus(unitId);
|
||||
}
|
||||
}, [dialogOpen, instances, layout, unitId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dialogOpen) {
|
||||
return;
|
||||
}
|
||||
const escape = shortcuts.forceEscape();
|
||||
return () => escape.dispose();
|
||||
}, [dialogOpen, shortcuts]);
|
||||
|
||||
function restoreFocus(event: Event) {
|
||||
event.preventDefault();
|
||||
layout.focus(unitId);
|
||||
}
|
||||
|
||||
function openDateTime() {
|
||||
const selection = selections.getActiveTextRange();
|
||||
const body = instances.getUnit<DocumentDataModel>(unitId)?.getSelfOrHeaderFooterModel(selection?.segmentId)?.getBody();
|
||||
if (!selection?.segmentId || !body) {
|
||||
return;
|
||||
}
|
||||
dialogFocusRef.current = { unitId, focusedUnitId: instances.getFocusedUnit()?.getUnitId() };
|
||||
setTarget({ unitId, selection: { ...selection }, segmentId: selection.segmentId, segmentPage: selection.segmentPage, expectedDataStream: body.dataStream });
|
||||
setPreviewDate(new Date());
|
||||
setError(false);
|
||||
}
|
||||
|
||||
async function insertDateTime() {
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
const result = await commands.executeCommand(InsertHeaderFooterFieldCommand.id, {
|
||||
...target,
|
||||
fieldType: format === 'time' ? 'TIME' : 'DATE',
|
||||
format,
|
||||
automatic,
|
||||
});
|
||||
if (result) {
|
||||
setTarget(null);
|
||||
} else {
|
||||
setError(true);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="univer-flex univer-flex-col univer-gap-1 univer-text-xs">
|
||||
<DropdownMenu
|
||||
onCloseAutoFocus={restoreFocus}
|
||||
disabled={disabled}
|
||||
items={[
|
||||
{ type: 'item', children: localeService.t<LocaleKey>('docs-ui.headerFooter.currentPage'), onSelect: () => commands.executeCommand(InsertHeaderFooterFieldCommand.id, { unitId, fieldType: 'PAGE' }) },
|
||||
{ type: 'item', children: localeService.t<LocaleKey>('docs-ui.headerFooter.totalPages'), onSelect: () => commands.executeCommand(InsertHeaderFooterFieldCommand.id, { unitId, fieldType: 'NUMPAGES' }) },
|
||||
{ type: 'separator' },
|
||||
{ type: 'item', children: localeService.t<LocaleKey>('docs-ui.headerFooter.refreshFields'), onSelect: () => commands.executeCommand(RefreshHeaderFooterFieldsCommand.id, { unitId }) },
|
||||
]}
|
||||
>
|
||||
<Button className="univer-justify-start" data-u-command={InsertHeaderFooterFieldCommand.id} size="small" variant="text" disabled={disabled} onMouseDown={(event) => event.preventDefault()}>
|
||||
<NumberIcon aria-hidden="true" />
|
||||
<span className="univer-flex-1 univer-text-start">{localeService.t<LocaleKey>('docs-ui.headerFooter.pageNumber')}</span>
|
||||
<MoreDownIcon aria-hidden="true" />
|
||||
</Button>
|
||||
</DropdownMenu>
|
||||
<Button className="univer-justify-start" data-u-command={InsertHeaderFooterFieldCommand.id} size="small" variant="text" disabled={disabled} onMouseDown={(event) => event.preventDefault()} onClick={openDateTime}>
|
||||
<CalendarIcon aria-hidden="true" />
|
||||
{localeService.t<LocaleKey>('docs-ui.headerFooter.dateTime')}
|
||||
</Button>
|
||||
<Dialog
|
||||
open={dialogOpen}
|
||||
width={360}
|
||||
title={localeService.t<LocaleKey>('docs-ui.headerFooter.dateTime')}
|
||||
showOk
|
||||
showCancel
|
||||
onClose={() => setTarget(null)}
|
||||
onCancel={() => setTarget(null)}
|
||||
onOk={insertDateTime}
|
||||
>
|
||||
<div className="univer-flex univer-flex-col univer-gap-4">
|
||||
<DropdownMenu
|
||||
align="start"
|
||||
className="univer-w-[--radix-popper-anchor-width]"
|
||||
items={[{
|
||||
type: 'radio',
|
||||
value: format,
|
||||
hideIndicator: true,
|
||||
onSelect: (value) => setFormat(value as DateTimeFieldFormat),
|
||||
options: (['short', 'long', 'time'] as const).map((value) => ({
|
||||
value,
|
||||
label: createDateTimeField(value, locale, previewDate).cachedResult,
|
||||
})),
|
||||
}]}
|
||||
>
|
||||
<Button
|
||||
aria-label={localeService.t<LocaleKey>('docs-ui.headerFooter.dateTime')}
|
||||
className={selectClassName}
|
||||
data-u-comp="select"
|
||||
>
|
||||
<span className="univer-flex-1 univer-truncate univer-text-start">
|
||||
{createDateTimeField(format, locale, previewDate).cachedResult}
|
||||
</span>
|
||||
<MoreDownIcon aria-hidden="true" />
|
||||
</Button>
|
||||
</DropdownMenu>
|
||||
<Checkbox checked={automatic} onChange={(value) => setAutomatic(Boolean(value))}>
|
||||
{localeService.t<LocaleKey>('docs-ui.headerFooter.updateAutomatically')}
|
||||
</Checkbox>
|
||||
{error && <p role="alert">{localeService.t<LocaleKey>('docs-ui.headerFooter.insertFailed')}</p>}
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* 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 type { LocaleKey } from '../../../locale/types';
|
||||
import {
|
||||
BooleanNumber,
|
||||
ICommandService,
|
||||
IPermissionService,
|
||||
IUniverInstanceService,
|
||||
LocaleService,
|
||||
UniverInstanceType,
|
||||
} from '@univerjs/core';
|
||||
import { Button, Checkbox, DropdownMenu, InputNumber } from '@univerjs/design';
|
||||
import { DocSkeletonManagerService, getDocumentPermissionValue } from '@univerjs/docs';
|
||||
import { DocumentEditArea, IRenderManagerService } from '@univerjs/engine-render';
|
||||
import { UnitAction } from '@univerjs/protocol';
|
||||
import { useDependency, useObservable } from '@univerjs/ui';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { OpenHeaderFooterPanelCommand } from '../../../commands/commands/doc-header-footer.command';
|
||||
import { DocHeaderFooterFields } from './DocHeaderFooterFields';
|
||||
import { useHeaderFooterOptions } from './use-header-footer-options';
|
||||
|
||||
interface IHeaderFooterRibbonControlProps {
|
||||
kind: 'insertion' | 'navigation' | 'options' | 'position';
|
||||
}
|
||||
|
||||
export function DocHeaderFooterRibbonControl(props: IHeaderFooterRibbonControlProps) {
|
||||
const instances = useDependency(IUniverInstanceService);
|
||||
const localeService = useDependency(LocaleService);
|
||||
useObservable(localeService.currentLocale$);
|
||||
const document = useObservable(() => instances.getCurrentTypeOfUnit$<DocumentDataModel>(UniverInstanceType.UNIVER_DOC), undefined, false, [instances]);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [compact, setCompact] = useState(false);
|
||||
const { kind } = props;
|
||||
useEffect(() => {
|
||||
const toolbar = containerRef.current?.closest('[role="toolbar"]');
|
||||
if (!toolbar || kind === 'insertion') {
|
||||
return;
|
||||
}
|
||||
const thresholds = { position: 1100, options: 900, navigation: 650 };
|
||||
const resize = () => setCompact(toolbar.getBoundingClientRect().width < thresholds[kind]);
|
||||
const observer = new ResizeObserver(resize);
|
||||
observer.observe(toolbar);
|
||||
return () => observer.disconnect();
|
||||
}, [kind, document]);
|
||||
if (!document) {
|
||||
return null;
|
||||
}
|
||||
const content = <HeaderFooterRibbonControl key={document.getUnitId()} {...props} unitId={document.getUnitId()} />;
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
data-u-command={OpenHeaderFooterPanelCommand.id}
|
||||
className="univer-flex univer-items-center univer-whitespace-nowrap"
|
||||
>
|
||||
{compact && kind !== 'insertion'
|
||||
? (
|
||||
<DropdownMenu items={[{ type: 'custom', className: 'univer-p-2', children: content }]}>
|
||||
<Button data-u-command={OpenHeaderFooterPanelCommand.id} size="small" variant="text" onMouseDown={(event) => event.preventDefault()}>
|
||||
{localeService.t<LocaleKey>(`docs-ui.headerFooter.${kind}`)}
|
||||
</Button>
|
||||
</DropdownMenu>
|
||||
)
|
||||
: content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HeaderFooterRibbonControl({ kind, unitId }: IHeaderFooterRibbonControlProps & { unitId: string }) {
|
||||
const localeService = useDependency(LocaleService);
|
||||
const commandService = useDependency(ICommandService);
|
||||
const renders = useDependency(IRenderManagerService);
|
||||
const permissionService = useDependency(IPermissionService);
|
||||
useObservable(permissionService.permissionPointUpdate$);
|
||||
const instances = useDependency(IUniverInstanceService);
|
||||
const disabled = Boolean(instances.getUnit<DocumentDataModel>(unitId)?.getSnapshot().disabled) || !getDocumentPermissionValue(permissionService, unitId, unitId, UnitAction.Edit);
|
||||
const { canLinkToPrevious, linkedToPrevious, options, handleLinkToPreviousChange, handleCheckboxChange, handleMarginChange } = useHeaderFooterOptions(unitId);
|
||||
const area = renders.getRenderUnitById(unitId)?.with(DocSkeletonManagerService).getViewModel().getEditArea();
|
||||
|
||||
if (kind === 'insertion') {
|
||||
return <DocHeaderFooterFields unitId={unitId} disabled={disabled} />;
|
||||
}
|
||||
if (kind === 'navigation') {
|
||||
return (
|
||||
<div
|
||||
className="
|
||||
univer-grid univer-grid-cols-[auto_auto] univer-items-center univer-gap-x-2 univer-gap-y-1
|
||||
univer-text-start univer-text-xs
|
||||
"
|
||||
>
|
||||
<Button data-u-command={OpenHeaderFooterPanelCommand.id} size="small" variant="text" disabled={disabled || area === DocumentEditArea.HEADER} onMouseDown={(event) => event.preventDefault()} onClick={() => commandService.executeCommand(OpenHeaderFooterPanelCommand.id, { editArea: DocumentEditArea.HEADER })}>
|
||||
{localeService.t<LocaleKey>('docs-ui.headerFooter.goToHeader')}
|
||||
</Button>
|
||||
<Checkbox disabled={disabled || !canLinkToPrevious} checked={linkedToPrevious} onChange={(value) => handleLinkToPreviousChange(Boolean(value))}>
|
||||
{localeService.t<LocaleKey>('docs-ui.headerFooter.linkToPrevious')}
|
||||
</Checkbox>
|
||||
<Button data-u-command={OpenHeaderFooterPanelCommand.id} size="small" variant="text" disabled={disabled || area === DocumentEditArea.FOOTER} onMouseDown={(event) => event.preventDefault()} onClick={() => commandService.executeCommand(OpenHeaderFooterPanelCommand.id, { editArea: DocumentEditArea.FOOTER })}>
|
||||
{localeService.t<LocaleKey>('docs-ui.headerFooter.goToFooter')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (kind === 'options') {
|
||||
return (
|
||||
<div className="univer-flex univer-flex-col univer-gap-2 univer-text-start univer-text-xs">
|
||||
<Checkbox disabled={disabled} checked={options.useFirstPageHeaderFooter === BooleanNumber.TRUE} onChange={(value) => handleCheckboxChange(Boolean(value), 'useFirstPageHeaderFooter')}>
|
||||
{localeService.t<LocaleKey>('docs-ui.headerFooter.firstPageCheckBox')}
|
||||
</Checkbox>
|
||||
<Checkbox disabled={disabled} checked={options.evenAndOddHeaders === BooleanNumber.TRUE} onChange={(value) => handleCheckboxChange(Boolean(value), 'evenAndOddHeaders')}>
|
||||
{localeService.t<LocaleKey>('docs-ui.headerFooter.oddEvenCheckBox')}
|
||||
</Checkbox>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="univer-flex univer-flex-col univer-gap-1 univer-text-start univer-text-xs">
|
||||
<label className="univer-grid univer-grid-cols-[1fr_5rem] univer-items-center univer-gap-x-2">
|
||||
<span>{localeService.t<LocaleKey>('docs-ui.headerFooter.headerTopMargin')}</span>
|
||||
<InputNumber disabled={disabled} min={0} max={200} precision={1} value={options.marginHeader} onChange={(value) => handleMarginChange(value ?? 0, 'marginHeader')} />
|
||||
</label>
|
||||
<label className="univer-grid univer-grid-cols-[1fr_5rem] univer-items-center univer-gap-x-2">
|
||||
<span>{localeService.t<LocaleKey>('docs-ui.headerFooter.footerBottomMargin')}</span>
|
||||
<InputNumber disabled={disabled} min={0} max={200} precision={1} value={options.marginFooter} onChange={(value) => handleMarginChange(value ?? 0, 'marginFooter')} />
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+221
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* 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, IDocumentData } from '@univerjs/core';
|
||||
import type { RenderUnit } from '@univerjs/engine-render';
|
||||
import {
|
||||
CustomRangeType,
|
||||
DocumentFlavor,
|
||||
ICommandService,
|
||||
IUniverInstanceService,
|
||||
LocaleService,
|
||||
LocaleType,
|
||||
Univer,
|
||||
UniverInstanceType,
|
||||
} from '@univerjs/core';
|
||||
import { ConfigProvider } from '@univerjs/design';
|
||||
import designEnUS from '@univerjs/design/locale/en-US';
|
||||
import {
|
||||
DocLayoutExecutorService,
|
||||
DocSelectionManagerService,
|
||||
DocSkeletonManagerService,
|
||||
DocStateChangeManagerService,
|
||||
DocStateEmitService,
|
||||
InsertTextCommand,
|
||||
RichTextEditingMutation,
|
||||
SetTextSelectionsOperation,
|
||||
} from '@univerjs/docs';
|
||||
import {
|
||||
CanvasColorService,
|
||||
ICanvasColorService,
|
||||
IRenderManagerService,
|
||||
RenderManagerService,
|
||||
} from '@univerjs/engine-render';
|
||||
import {
|
||||
DesktopLayoutService,
|
||||
ILayoutService,
|
||||
IPlatformService,
|
||||
IShortcutService,
|
||||
IUIRuntimeScopeService,
|
||||
PlatformService,
|
||||
RediContext,
|
||||
ShortcutService,
|
||||
UIRuntimeScopeService,
|
||||
} from '@univerjs/ui';
|
||||
import { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { InsertHeaderFooterFieldCommand } from '../../../../commands/commands/insert-header-footer-field.command';
|
||||
import enUS from '../../../../locale/en-US';
|
||||
import { DocMenuStyleService } from '../../../../services/doc-menu-style.service';
|
||||
import { DocHeaderFooterFields } from '../DocHeaderFooterFields';
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
const cleanups: Array<() => void> = [];
|
||||
|
||||
async function renderFields(hostFocused = false) {
|
||||
const univer = new Univer();
|
||||
cleanups.push(() => univer.dispose());
|
||||
const injector = univer.__getInjector();
|
||||
injector.add([ILayoutService, { useClass: DesktopLayoutService }]);
|
||||
injector.add([IPlatformService, { useClass: PlatformService }]);
|
||||
injector.add([IUIRuntimeScopeService, { useClass: UIRuntimeScopeService }]);
|
||||
injector.add([IShortcutService, { useClass: ShortcutService }]);
|
||||
injector.add([IRenderManagerService, { useClass: RenderManagerService }]);
|
||||
injector.add([ICanvasColorService, { useClass: CanvasColorService }]);
|
||||
injector.add([DocSelectionManagerService]);
|
||||
injector.add([DocLayoutExecutorService]);
|
||||
injector.add([DocStateEmitService]);
|
||||
injector.add([DocStateChangeManagerService]);
|
||||
injector.add([DocMenuStyleService]);
|
||||
const locale = injector.get(LocaleService);
|
||||
locale.load({ [LocaleType.EN_US]: enUS });
|
||||
locale.setLocale(LocaleType.EN_US);
|
||||
locale.setDirection('ltr');
|
||||
const model = univer.createUnit<IDocumentData, DocumentDataModel>(UniverInstanceType.UNIVER_DOC, {
|
||||
id: 'header-footer-fields-ui',
|
||||
body: { dataStream: 'Body\r\n', textRuns: [], paragraphs: [{ paragraphId: 'body-p', startIndex: 4 }] },
|
||||
headers: { header: { headerId: 'header', body: { dataStream: 'Header\r\n', textRuns: [], paragraphs: [{ paragraphId: 'header-p', startIndex: 6 }] } } },
|
||||
documentStyle: { documentFlavor: DocumentFlavor.TRADITIONAL, defaultHeaderId: 'header', pageSize: { width: 300, height: 400 }, marginTop: 40 },
|
||||
});
|
||||
const instances = injector.get(IUniverInstanceService);
|
||||
instances.focusUnit(model.getUnitId());
|
||||
const render = injector.get(IRenderManagerService).createRender(model.getUnitId()) as RenderUnit;
|
||||
render.deactivate();
|
||||
render.addRenderDependencies([[DocSkeletonManagerService]]);
|
||||
render.with(DocSkeletonManagerService).getSkeleton().calculate();
|
||||
const commands = injector.get(ICommandService);
|
||||
for (const command of [InsertHeaderFooterFieldCommand, InsertTextCommand, RichTextEditingMutation, SetTextSelectionsOperation]) {
|
||||
commands.registerCommand(command);
|
||||
}
|
||||
injector.get(DocStateChangeManagerService);
|
||||
const selections = injector.get(DocSelectionManagerService);
|
||||
selections.__TEST_ONLY_setCurrentSelection({ unitId: model.getUnitId(), subUnitId: model.getUnitId() });
|
||||
selections.__TEST_ONLY_add([{ startOffset: 6, endOffset: 6, collapsed: true, isActive: true, segmentId: 'header', segmentPage: 0 }]);
|
||||
const container = document.createElement('div');
|
||||
cleanups.push(() => container.remove());
|
||||
document.body.appendChild(container);
|
||||
const editor = document.createElement('textarea');
|
||||
container.appendChild(editor);
|
||||
const focusRequests: string[] = [];
|
||||
const focusHandler = injector.get(ILayoutService).registerFocusHandler(UniverInstanceType.UNIVER_DOC, (unitId) => {
|
||||
focusRequests.push(unitId);
|
||||
editor.focus();
|
||||
});
|
||||
cleanups.push(() => focusHandler.dispose());
|
||||
const mount = document.createElement('div');
|
||||
container.appendChild(mount);
|
||||
const root = createRoot(mount);
|
||||
cleanups.push(() => act(() => root.unmount()));
|
||||
await act(async () => root.render(
|
||||
<RediContext.Provider value={{ injector }}>
|
||||
<ConfigProvider locale={designEnUS.design} mountContainer={document.body}>
|
||||
<DocHeaderFooterFields unitId={model.getUnitId()} disabled={false} />
|
||||
</ConfigProvider>
|
||||
</RediContext.Provider>
|
||||
));
|
||||
if (hostFocused) {
|
||||
const host = univer.createUnit(UniverInstanceType.UNIVER_SHEET, { id: 'header-fields-host' });
|
||||
instances.focusUnit(host.getUnitId());
|
||||
}
|
||||
editor.focus();
|
||||
await act(async () => button(mount, 'Date & time').click());
|
||||
return { univer, model, injector, container, editor, mount, focusRequests };
|
||||
}
|
||||
|
||||
function button(container: ParentNode, text: string) {
|
||||
const result = Array.from(container.querySelectorAll<HTMLButtonElement>('button')).find((item) => item.textContent === text);
|
||||
expect(result).toBeDefined();
|
||||
return result!;
|
||||
}
|
||||
|
||||
async function settleFocus() {
|
||||
await act(async () => new Promise((resolve) => setTimeout(resolve, 0)));
|
||||
}
|
||||
|
||||
describe('DocHeaderFooterFields', () => {
|
||||
afterEach(async () => {
|
||||
cleanups.splice(0).reverse().forEach((dispose) => dispose());
|
||||
await settleFocus();
|
||||
});
|
||||
|
||||
it.each(['Cancel', 'Close', 'Escape'])('returns focus to the header after %s', async (action) => {
|
||||
const { editor } = await renderFields();
|
||||
const dialog = document.querySelector<HTMLElement>('[role="dialog"]')!;
|
||||
expect(dialog.contains(document.activeElement)).toBe(true);
|
||||
await act(async () => {
|
||||
if (action === 'Escape') {
|
||||
document.activeElement?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||
} else {
|
||||
button(dialog, action === 'Cancel' ? designEnUS.design.Confirm.cancel : action).click();
|
||||
}
|
||||
});
|
||||
await settleFocus();
|
||||
expect(document.querySelector('[role="dialog"]')).toBeNull();
|
||||
expect(document.activeElement).toBe(editor);
|
||||
});
|
||||
|
||||
it('opens the date format by keyboard, inserts the selected format and resumes editing', async () => {
|
||||
const { model, injector, editor } = await renderFields();
|
||||
const trigger = document.querySelector<HTMLButtonElement>('button[data-u-comp="select"]')!;
|
||||
expect(trigger.tagName).toBe('BUTTON');
|
||||
expect(trigger.getAttribute('aria-label')).toBe('Date & time');
|
||||
expect(document.querySelector('[role="dialog"]')!.contains(trigger)).toBe(true);
|
||||
await act(async () => {
|
||||
trigger.focus();
|
||||
trigger.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
||||
});
|
||||
const longDate = Array.from(document.querySelectorAll<HTMLElement>('[role="menuitemradio"]')).find((item) => item.textContent?.includes(','))!;
|
||||
expect(longDate).toBeDefined();
|
||||
const selectedDate = longDate.textContent;
|
||||
await act(async () => {
|
||||
longDate.focus();
|
||||
longDate.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
||||
});
|
||||
expect(trigger.textContent).toBe(selectedDate);
|
||||
await act(async () => button(document.querySelector('[role="dialog"]')!, designEnUS.design.Confirm.confirm).click());
|
||||
injector.get(DocStateChangeManagerService).flushPendingChanges(model.getUnitId());
|
||||
await settleFocus();
|
||||
const field = model.getSelfOrHeaderFooterModel('header')!.getBody()!.customRanges?.find((range) => range.rangeType === CustomRangeType.FIELD);
|
||||
expect(field?.properties?.cachedResult).toBe(selectedDate);
|
||||
expect(field?.properties?.instruction).toContain('MMMM');
|
||||
expect(document.activeElement).toBe(editor);
|
||||
});
|
||||
|
||||
it('does not take focus from a document activated while the dialog was open', async () => {
|
||||
const { univer, injector, container, editor, focusRequests } = await renderFields();
|
||||
const other = univer.createUnit<IDocumentData, DocumentDataModel>(UniverInstanceType.UNIVER_DOC, { id: 'other-doc', body: { dataStream: '\r\n', textRuns: [] } });
|
||||
const otherEditor = document.createElement('textarea');
|
||||
container.appendChild(otherEditor);
|
||||
injector.get(IUniverInstanceService).focusUnit(other.getUnitId());
|
||||
const requestsBeforeClose = focusRequests.length;
|
||||
await act(async () => button(document.querySelector('[role="dialog"]')!, designEnUS.design.Confirm.cancel).click());
|
||||
otherEditor.focus();
|
||||
await settleFocus();
|
||||
expect(document.activeElement).toBe(otherEditor);
|
||||
expect(document.activeElement).not.toBe(editor);
|
||||
expect(focusRequests).toHaveLength(requestsBeforeClose);
|
||||
});
|
||||
|
||||
it('restores the owning editor while the host keeps global unit focus', async () => {
|
||||
const { injector, editor } = await renderFields(true);
|
||||
await act(async () => button(document.querySelector('[role="dialog"]')!, designEnUS.design.Confirm.cancel).click());
|
||||
await settleFocus();
|
||||
expect(document.activeElement).toBe(editor);
|
||||
expect(injector.get(IUniverInstanceService).getFocusedUnit()?.getUnitId()).toBe('header-fields-host');
|
||||
});
|
||||
});
|
||||
+146
-311
@@ -18,363 +18,198 @@
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
|
||||
import type { ICommand, IDisposable } from '@univerjs/core';
|
||||
import type { Root } from 'react-dom/client';
|
||||
import type { ICoreHeaderFooterParams } from '../../../../commands/commands/doc-header-footer.command';
|
||||
import type { DocumentDataModel, IDocumentData } from '@univerjs/core';
|
||||
import type { ISuccinctDocRangeParam, RenderUnit } from '@univerjs/engine-render';
|
||||
import {
|
||||
BooleanNumber,
|
||||
CommandService,
|
||||
CommandType,
|
||||
ConfigService,
|
||||
ContextService,
|
||||
DocumentDataModel,
|
||||
DataStreamTreeTokenType,
|
||||
DocumentFlavor,
|
||||
ICommandService,
|
||||
IConfigService,
|
||||
IContextService,
|
||||
ILogService,
|
||||
Injector,
|
||||
IUniverInstanceService,
|
||||
LocaleService,
|
||||
toDisposable,
|
||||
UniverInstanceService,
|
||||
LocaleType,
|
||||
SectionType,
|
||||
Univer,
|
||||
UniverInstanceType,
|
||||
} from '@univerjs/core';
|
||||
import { DocSkeletonManagerService } from '@univerjs/docs';
|
||||
import { DocumentEditArea, IRenderManagerService } from '@univerjs/engine-render';
|
||||
import { ILayoutService, RediContext } from '@univerjs/ui';
|
||||
import {
|
||||
CreateHeaderFooterCommand,
|
||||
DocLayoutExecutorService,
|
||||
DocSelectionManagerService,
|
||||
DocSkeletonManagerService,
|
||||
DocStateChangeManagerService,
|
||||
DocStateEmitService,
|
||||
RichTextEditingMutation,
|
||||
SetTextSelectionsOperation,
|
||||
} from '@univerjs/docs';
|
||||
import {
|
||||
CanvasColorService,
|
||||
DocumentEditArea,
|
||||
ICanvasColorService,
|
||||
IRenderManagerService,
|
||||
RenderManagerService,
|
||||
} from '@univerjs/engine-render';
|
||||
import {
|
||||
DesktopLayoutService,
|
||||
DesktopRibbonService,
|
||||
ILayoutService,
|
||||
IMenuManagerService,
|
||||
IRibbonService,
|
||||
MenuManagerService,
|
||||
RediContext,
|
||||
} from '@univerjs/ui';
|
||||
import { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { BehaviorSubject, Subject } from 'rxjs';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { CloseHeaderFooterCommand, CoreHeaderFooterCommandId } from '../../../../commands/commands/doc-header-footer.command';
|
||||
import {
|
||||
CloseHeaderFooterCommand,
|
||||
CoreHeaderFooterCommand,
|
||||
} from '../../../../commands/commands/doc-header-footer.command';
|
||||
import { DocHeaderFooterRibbonOperation } from '../../../../commands/operations/doc-header-footer-ribbon.operation';
|
||||
import enUS from '../../../../locale/en-US';
|
||||
import { DocSelectionRenderService } from '../../../../services/selection/doc-selection-render.service';
|
||||
import { DocHeaderFooterOptions } from '../DocHeaderFooterOptions';
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
const UNIT_ID = 'header-footer-options-doc';
|
||||
const cleanups: Array<() => void> = [];
|
||||
|
||||
interface IRecordedCommand {
|
||||
id: string;
|
||||
params: unknown;
|
||||
}
|
||||
|
||||
class TestLogService {
|
||||
debug(): void {}
|
||||
warn(): void {}
|
||||
}
|
||||
|
||||
class TestLocaleService {
|
||||
t(key: string): string {
|
||||
return key;
|
||||
}
|
||||
}
|
||||
|
||||
class TestLayoutService {
|
||||
private _focusCount = 0;
|
||||
|
||||
get focusCount() {
|
||||
return this._focusCount;
|
||||
}
|
||||
|
||||
focus(): void {
|
||||
this._focusCount += 1;
|
||||
}
|
||||
|
||||
registerFocusHandler(): IDisposable {
|
||||
return toDisposable(() => undefined);
|
||||
}
|
||||
|
||||
registerRootContainerElement(): IDisposable {
|
||||
return toDisposable(() => undefined);
|
||||
}
|
||||
|
||||
registerContentElement(): IDisposable {
|
||||
return toDisposable(() => undefined);
|
||||
}
|
||||
|
||||
registerContainerElement(): IDisposable {
|
||||
return toDisposable(() => undefined);
|
||||
}
|
||||
|
||||
getContentElement(): HTMLElement {
|
||||
return document.body;
|
||||
}
|
||||
|
||||
checkElementInCurrentContainers(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
checkContentIsFocused(): boolean {
|
||||
return this._focusCount > 0;
|
||||
}
|
||||
}
|
||||
|
||||
class TestSelectionRenderService {
|
||||
readonly segmentContext$ = new BehaviorSubject({ segmentId: 'default-header', segmentPage: 3 });
|
||||
private _segment = 'default-header';
|
||||
private _segmentPage = 3;
|
||||
private _removeRangeCount = 0;
|
||||
private _blurCount = 0;
|
||||
|
||||
get segment() {
|
||||
return this._segment;
|
||||
}
|
||||
|
||||
get removeRangeCount() {
|
||||
return this._removeRangeCount;
|
||||
}
|
||||
|
||||
get blurCount() {
|
||||
return this._blurCount;
|
||||
}
|
||||
|
||||
getSegment(): string {
|
||||
return this._segment;
|
||||
}
|
||||
|
||||
setSegment(segmentId: string): void {
|
||||
this._segment = segmentId;
|
||||
this.segmentContext$.next({ segmentId, segmentPage: this._segmentPage });
|
||||
}
|
||||
|
||||
getSegmentPage(): number {
|
||||
return this._segmentPage;
|
||||
}
|
||||
|
||||
setSegmentPage(page: number): void {
|
||||
this._segmentPage = page;
|
||||
this.segmentContext$.next({ segmentId: this._segment, segmentPage: page });
|
||||
}
|
||||
|
||||
removeAllRanges(): void {
|
||||
this._removeRangeCount += 1;
|
||||
}
|
||||
|
||||
blur(): void {
|
||||
this._blurCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
class TestDocumentViewModel {
|
||||
readonly editAreaChange$ = new Subject<DocumentEditArea | null>();
|
||||
private _editArea = DocumentEditArea.HEADER;
|
||||
|
||||
getEditArea(): DocumentEditArea {
|
||||
return this._editArea;
|
||||
}
|
||||
|
||||
setEditArea(editArea: DocumentEditArea): void {
|
||||
this._editArea = editArea;
|
||||
this.editAreaChange$.next(editArea);
|
||||
}
|
||||
}
|
||||
|
||||
class TestDocSkeletonManagerService {
|
||||
constructor(private readonly _viewModel: TestDocumentViewModel) {}
|
||||
|
||||
getViewModel(): TestDocumentViewModel {
|
||||
return this._viewModel;
|
||||
}
|
||||
|
||||
getSkeleton() {
|
||||
return {
|
||||
getSkeletonData: () => ({
|
||||
pages: [undefined, undefined, undefined, { sectionId: 'section_options', pageNumber: 1, pageNumberStart: 1 }],
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class TestRenderUnit {
|
||||
constructor(
|
||||
private readonly _selectionRenderService: TestSelectionRenderService,
|
||||
private readonly _docSkeletonManagerService: TestDocSkeletonManagerService
|
||||
) {}
|
||||
|
||||
with(token: unknown) {
|
||||
if (token === DocSelectionRenderService) {
|
||||
return this._selectionRenderService;
|
||||
}
|
||||
if (token === DocSkeletonManagerService) {
|
||||
return this._docSkeletonManagerService;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class TestRenderManagerService {
|
||||
static renderUnit: TestRenderUnit;
|
||||
|
||||
getRenderUnitById(): TestRenderUnit {
|
||||
return TestRenderManagerService.renderUnit;
|
||||
}
|
||||
}
|
||||
|
||||
function createHeaderFooterOptionsTestBed() {
|
||||
const injector = new Injector();
|
||||
const records: IRecordedCommand[] = [];
|
||||
const selectionRenderService = new TestSelectionRenderService();
|
||||
const viewModel = new TestDocumentViewModel();
|
||||
|
||||
TestRenderManagerService.renderUnit = new TestRenderUnit(
|
||||
selectionRenderService,
|
||||
new TestDocSkeletonManagerService(viewModel)
|
||||
);
|
||||
|
||||
injector.add([ILogService, { useClass: TestLogService as never }]);
|
||||
injector.add([IConfigService, { useClass: ConfigService }]);
|
||||
injector.add([IContextService, { useClass: ContextService }]);
|
||||
injector.add([ICommandService, { useClass: CommandService }]);
|
||||
injector.add([IUniverInstanceService, { useClass: UniverInstanceService }]);
|
||||
injector.add([LocaleService, { useClass: TestLocaleService as never }]);
|
||||
injector.add([IRenderManagerService, { useClass: TestRenderManagerService as never }]);
|
||||
injector.add([ILayoutService, { useClass: TestLayoutService as never }]);
|
||||
|
||||
const doc = new DocumentDataModel({
|
||||
async function renderHeaderFooterOptions() {
|
||||
const univer = new Univer();
|
||||
cleanups.push(() => univer.dispose());
|
||||
const injector = univer.__getInjector();
|
||||
injector.add([IRenderManagerService, { useClass: RenderManagerService }]);
|
||||
injector.add([ICanvasColorService, { useClass: CanvasColorService }]);
|
||||
injector.add([ILayoutService, { useClass: DesktopLayoutService }]);
|
||||
injector.add([IMenuManagerService, { useClass: MenuManagerService }]);
|
||||
injector.add([IRibbonService, { useClass: DesktopRibbonService }]);
|
||||
injector.add([DocLayoutExecutorService]);
|
||||
injector.add([DocSelectionManagerService]);
|
||||
injector.add([DocStateEmitService]);
|
||||
injector.add([DocStateChangeManagerService]);
|
||||
const locale = injector.get(LocaleService);
|
||||
locale.load({ [LocaleType.EN_US]: enUS });
|
||||
locale.setLocale(LocaleType.EN_US);
|
||||
locale.setDirection('ltr');
|
||||
const first = `A${DataStreamTreeTokenType.PAGE_BREAK}B${DataStreamTreeTokenType.PAGE_BREAK}C\r\n`;
|
||||
const model = univer.createUnit<IDocumentData, DocumentDataModel>(UniverInstanceType.UNIVER_DOC, {
|
||||
id: UNIT_ID,
|
||||
documentStyle: {
|
||||
documentFlavor: DocumentFlavor.TRADITIONAL,
|
||||
pageSize: { width: 300, height: 400 },
|
||||
defaultHeaderId: 'default-header',
|
||||
defaultFooterId: 'default-footer',
|
||||
marginTop: 40,
|
||||
marginHeader: 12,
|
||||
marginFooter: 18,
|
||||
useFirstPageHeaderFooter: BooleanNumber.FALSE,
|
||||
evenAndOddHeaders: BooleanNumber.FALSE,
|
||||
},
|
||||
headers: { 'default-header': { headerId: 'default-header', body: {
|
||||
dataStream: 'Header\r\n',
|
||||
paragraphs: [{ paragraphId: 'header-p', startIndex: 6 }],
|
||||
textRuns: [],
|
||||
} } },
|
||||
body: {
|
||||
dataStream: '\r\n',
|
||||
paragraphs: [],
|
||||
sectionBreaks: [{ sectionId: 'section_options', startIndex: 1 }],
|
||||
customRanges: [],
|
||||
tables: [],
|
||||
dataStream: `${first}D\r\n`,
|
||||
paragraphs: [
|
||||
{ paragraphId: 'first-p', startIndex: first.length - 2 },
|
||||
{ paragraphId: 'second-p', startIndex: first.length + 1 },
|
||||
],
|
||||
sectionBreaks: [
|
||||
{ sectionId: 'first-section', startIndex: first.length - 1 },
|
||||
{
|
||||
sectionId: 'section_options',
|
||||
startIndex: first.length + 2,
|
||||
sectionType: SectionType.NEXT_PAGE,
|
||||
pageNumberStart: 1,
|
||||
},
|
||||
],
|
||||
textRuns: [],
|
||||
},
|
||||
});
|
||||
(injector.get(IUniverInstanceService) as UniverInstanceService).__addUnit(doc);
|
||||
|
||||
const commandService = injector.get(ICommandService);
|
||||
commandService.registerCommand(createRecordingCommand(CoreHeaderFooterCommandId, records));
|
||||
commandService.registerCommand(createRecordingCommand(CloseHeaderFooterCommand.id, records));
|
||||
|
||||
return {
|
||||
injector,
|
||||
records,
|
||||
selectionRenderService,
|
||||
viewModel,
|
||||
layoutService: injector.get(ILayoutService) as unknown as TestLayoutService,
|
||||
};
|
||||
}
|
||||
|
||||
function createRecordingCommand(id: string, records: IRecordedCommand[]): ICommand {
|
||||
return {
|
||||
id,
|
||||
type: CommandType.COMMAND,
|
||||
handler: (_accessor, params) => {
|
||||
records.push({ id, params });
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function renderHeaderFooterOptions() {
|
||||
const testBed = createHeaderFooterOptionsTestBed();
|
||||
injector.get(IUniverInstanceService).focusUnit(UNIT_ID);
|
||||
const render = injector.get(IRenderManagerService).createRender(UNIT_ID) as RenderUnit;
|
||||
render.deactivate();
|
||||
render.addRenderDependencies([[DocSkeletonManagerService], [DocSelectionRenderService]]);
|
||||
const skeletonManager = render.with(DocSkeletonManagerService);
|
||||
skeletonManager.getSkeleton().calculate();
|
||||
skeletonManager.getViewModel().setEditArea(DocumentEditArea.HEADER);
|
||||
const selection = render.with(DocSelectionRenderService);
|
||||
selection.setSegmentPage(3);
|
||||
selection.setSegment('default-header');
|
||||
const commands = injector.get(ICommandService);
|
||||
for (const command of [CoreHeaderFooterCommand, CreateHeaderFooterCommand, RichTextEditingMutation, SetTextSelectionsOperation, CloseHeaderFooterCommand, DocHeaderFooterRibbonOperation]) {
|
||||
commands.registerCommand(command);
|
||||
}
|
||||
const state = { ranges: [] as ISuccinctDocRangeParam[] };
|
||||
const subscription = injector.get(DocSelectionManagerService).refreshSelection$.subscribe((event) => {
|
||||
if (event) {
|
||||
state.ranges = event.docRanges;
|
||||
}
|
||||
});
|
||||
cleanups.push(() => subscription.unsubscribe());
|
||||
const container = document.createElement('div');
|
||||
cleanups.push(() => container.remove());
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
cleanups.push(() => act(() => root.unmount()));
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<RediContext.Provider value={{ injector: testBed.injector }}>
|
||||
<RediContext.Provider value={{ injector }}>
|
||||
<DocHeaderFooterOptions unitId={UNIT_ID} />
|
||||
</RediContext.Provider>
|
||||
);
|
||||
});
|
||||
|
||||
return { ...testBed, container, root };
|
||||
}
|
||||
|
||||
function clickCheckbox(input: HTMLInputElement) {
|
||||
input.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
return { model, injector, selection, skeletonManager, container, state };
|
||||
}
|
||||
|
||||
function changeInput(input: HTMLInputElement, value: string) {
|
||||
const prototypeValueSetter = Object.getOwnPropertyDescriptor(
|
||||
Object.getPrototypeOf(input),
|
||||
'value'
|
||||
)?.set;
|
||||
|
||||
prototypeValueSetter?.call(input, value);
|
||||
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call(input, value);
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
|
||||
describe('DocHeaderFooterOptions', () => {
|
||||
let root: Root | undefined;
|
||||
let container: HTMLElement | undefined;
|
||||
afterEach(() => cleanups.splice(0).reverse().forEach((dispose) => dispose()));
|
||||
|
||||
afterEach(() => {
|
||||
if (root) {
|
||||
act(() => root!.unmount());
|
||||
it('creates and selects a first-page header at a restarted section on the fourth physical page', async () => {
|
||||
const rendered = await renderHeaderFooterOptions();
|
||||
const { container } = rendered;
|
||||
expect(rendered.skeletonManager.getSkeleton().getSkeletonData()?.pages.map((page) => page.pageNumber))
|
||||
.toEqual([1, 2, 3, 1]);
|
||||
const firstPageCheckbox = container.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')[1];
|
||||
const focus = document.createElement('input');
|
||||
container.appendChild(focus);
|
||||
const registration = rendered.injector.get(ILayoutService).registerFocusHandler(
|
||||
UniverInstanceType.UNIVER_DOC,
|
||||
() => focus.focus()
|
||||
);
|
||||
try {
|
||||
await act(async () => firstPageCheckbox.click());
|
||||
const section = rendered.model.getSnapshot().body!.sectionBreaks![1];
|
||||
expect(section.useFirstPageHeaderFooter).toBe(BooleanNumber.TRUE);
|
||||
expect(section.firstPageHeaderId).toBeTruthy();
|
||||
expect(rendered.model.getSelfOrHeaderFooterModel(section.firstPageHeaderId)).toBeDefined();
|
||||
expect(rendered.selection.getSegment()).toBe(section.firstPageHeaderId);
|
||||
expect(document.activeElement).toBe(focus);
|
||||
} finally {
|
||||
registration.dispose();
|
||||
}
|
||||
container?.remove();
|
||||
root = undefined;
|
||||
container = undefined;
|
||||
});
|
||||
|
||||
it('turns on first-page header/footer and focuses the newly created current-page segment', () => {
|
||||
const rendered = renderHeaderFooterOptions();
|
||||
root = rendered.root;
|
||||
container = rendered.container;
|
||||
|
||||
const [firstPageCheckbox] = Array.from(container.querySelectorAll('input[type="checkbox"]')) as HTMLInputElement[];
|
||||
|
||||
act(() => clickCheckbox(firstPageCheckbox));
|
||||
|
||||
const [record] = rendered.records;
|
||||
const params = record.params as ICoreHeaderFooterParams;
|
||||
|
||||
expect(record.id).toBe(CoreHeaderFooterCommandId);
|
||||
expect(params.unitId).toBe(UNIT_ID);
|
||||
expect(params.headerFooterProps).toEqual({
|
||||
useFirstPageHeaderFooter: BooleanNumber.TRUE,
|
||||
});
|
||||
expect(params.sectionId).toBe('section_options');
|
||||
expect(params.segmentId).toHaveLength(6);
|
||||
expect(rendered.selectionRenderService.segment).toBe(params.segmentId);
|
||||
expect(rendered.layoutService.focusCount).toBe(1);
|
||||
});
|
||||
|
||||
it('sends margin edits and close actions through header/footer commands', async () => {
|
||||
const rendered = renderHeaderFooterOptions();
|
||||
root = rendered.root;
|
||||
container = rendered.container;
|
||||
|
||||
const [headerMarginInput] = Array.from(container.querySelectorAll('input[type="text"]')) as HTMLInputElement[];
|
||||
it('persists section margin edits and returns the caret to the body when closed', async () => {
|
||||
const rendered = await renderHeaderFooterOptions();
|
||||
const { container } = rendered;
|
||||
const headerMargin = container.querySelector<HTMLInputElement>('input[type="text"]')!;
|
||||
await act(async () => changeInput(headerMargin, '25.5'));
|
||||
expect(rendered.model.getSnapshot().body!.sectionBreaks![1].marginHeader).toBe(25.5);
|
||||
expect(rendered.model.getSnapshot().documentStyle.marginHeader).toBe(12);
|
||||
const closeButton = Array.from(container.querySelectorAll('button')).find((button) => (
|
||||
button.textContent?.includes('docs-ui.headerFooter.closeHeaderFooter')
|
||||
));
|
||||
|
||||
if (!closeButton) {
|
||||
throw new Error('Missing close header footer button');
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
changeInput(headerMarginInput, '25.5');
|
||||
});
|
||||
act(() => {
|
||||
closeButton.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(rendered.records.map((record) => record.id)).toEqual([
|
||||
CoreHeaderFooterCommandId,
|
||||
CloseHeaderFooterCommand.id,
|
||||
]);
|
||||
expect((rendered.records[0].params as ICoreHeaderFooterParams).headerFooterProps).toEqual({
|
||||
marginHeader: 25.5,
|
||||
});
|
||||
expect((rendered.records[0].params as ICoreHeaderFooterParams).sectionId).toBe('section_options');
|
||||
expect((rendered.records[1].params as { unitId: string })).toEqual({
|
||||
unitId: UNIT_ID,
|
||||
});
|
||||
expect(rendered.selectionRenderService.removeRangeCount).toBe(1);
|
||||
expect(rendered.selectionRenderService.blurCount).toBe(1);
|
||||
button.textContent?.includes(rendered.injector.get(LocaleService).t('docs-ui.headerFooter.closeHeaderFooter'))
|
||||
))!;
|
||||
await act(async () => closeButton.click());
|
||||
expect(rendered.skeletonManager.getViewModel().getEditArea()).toBe(DocumentEditArea.BODY);
|
||||
expect(rendered.selection.getSegment()).toBe('');
|
||||
expect(rendered.selection.getSegmentPage()).toBe(-1);
|
||||
expect(rendered.state.ranges).toEqual([expect.objectContaining({ startOffset: 0, endOffset: 0 })]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,3 +15,6 @@
|
||||
*/
|
||||
|
||||
export const COMPONENT_DOC_HEADER_FOOTER_PANEL = 'COMPONENT_DOC_HEADER_FOOTER_PANEL';
|
||||
|
||||
export const DOC_HEADER_FOOTER_RIBBON_TAB = 'docs-ui.ribbon.header-footer';
|
||||
export const DOC_HEADER_FOOTER_RIBBON_CONTROL = 'DocHeaderFooterRibbonControl';
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
|
||||
import type { DocumentDataModel, IDocumentStyle } from '@univerjs/core';
|
||||
import type { IHeaderFooterProps } from '@univerjs/docs';
|
||||
import type { IHeaderFooterProps, IRichTextEditingMutationParams } from '@univerjs/docs';
|
||||
import type { IDocumentSkeletonPage } from '@univerjs/engine-render';
|
||||
import {
|
||||
BooleanNumber,
|
||||
@@ -26,10 +26,10 @@ import {
|
||||
resolveSectionHeaderFooterReference,
|
||||
UniverInstanceType,
|
||||
} from '@univerjs/core';
|
||||
import { DocSkeletonManagerService, SetSectionHeaderFooterLinkCommand } from '@univerjs/docs';
|
||||
import { DocSkeletonManagerService, RichTextEditingMutation, SetSectionHeaderFooterLinkCommand } from '@univerjs/docs';
|
||||
import { DocumentEditArea, IRenderManagerService } from '@univerjs/engine-render';
|
||||
import { ILayoutService, useDependency } from '@univerjs/ui';
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useReducer } from 'react';
|
||||
import {
|
||||
CloseHeaderFooterCommand,
|
||||
CoreHeaderFooterCommandId,
|
||||
@@ -90,16 +90,27 @@ export function useHeaderFooterOptions(unitId: string) {
|
||||
const page = docSkeletonManagerService?.getSkeleton?.()?.getSkeletonData()?.pages[docSelectionRenderService.getSegmentPage()];
|
||||
return snapshot == null ? undefined : { ...getDocPageSectionContext(snapshot, page), page };
|
||||
};
|
||||
const [options, setOptions] = useState<IHeaderFooterProps>(() => {
|
||||
const config = getCurrentSectionContext()?.config;
|
||||
return {
|
||||
marginHeader: config?.marginHeader ?? 0,
|
||||
marginFooter: config?.marginFooter ?? 0,
|
||||
useFirstPageHeaderFooter: config?.useFirstPageHeaderFooter ?? BooleanNumber.FALSE,
|
||||
evenAndOddHeaders: config?.evenAndOddHeaders ?? BooleanNumber.FALSE,
|
||||
const [, refresh] = useReducer((revision: number) => revision + 1, 0);
|
||||
useEffect(() => {
|
||||
const commandListener = commandService.onCommandExecuted((command) => {
|
||||
if (command.id === RichTextEditingMutation.id && (command.params as IRichTextEditingMutationParams | undefined)?.unitId === unitId) {
|
||||
refresh();
|
||||
}
|
||||
});
|
||||
const segmentListener = docSelectionRenderService.segmentContext$.subscribe(() => refresh());
|
||||
return () => {
|
||||
commandListener.dispose();
|
||||
segmentListener.unsubscribe();
|
||||
};
|
||||
});
|
||||
}, [commandService, docSelectionRenderService, unitId]);
|
||||
const sectionContext = getCurrentSectionContext();
|
||||
const config = sectionContext?.config;
|
||||
const options: IHeaderFooterProps = {
|
||||
marginHeader: config?.marginHeader ?? 0,
|
||||
marginFooter: config?.marginFooter ?? 0,
|
||||
useFirstPageHeaderFooter: config?.useFirstPageHeaderFooter ?? BooleanNumber.FALSE,
|
||||
evenAndOddHeaders: config?.evenAndOddHeaders ?? BooleanNumber.FALSE,
|
||||
};
|
||||
const editArea = renderManagerService.getRenderUnitById(unitId)?.with(DocSkeletonManagerService)?.getViewModel()?.getEditArea();
|
||||
const headerFooterKind = editArea === DocumentEditArea.FOOTER ? 'footer' : 'header';
|
||||
const variant = getHeaderFooterVariant(
|
||||
@@ -109,16 +120,9 @@ export function useHeaderFooterOptions(unitId: string) {
|
||||
);
|
||||
const referenceKey = getSectionHeaderFooterReferenceKey(headerFooterKind, variant);
|
||||
const canLinkToPrevious = (sectionContext?.sectionIndex ?? -1) > 0;
|
||||
const [linkedToPrevious, setLinkedToPrevious] = useState(
|
||||
canLinkToPrevious && !sectionContext?.section?.[referenceKey]
|
||||
);
|
||||
|
||||
const handleCheckboxChange = (val: boolean, type: 'useFirstPageHeaderFooter' | 'evenAndOddHeaders') => {
|
||||
setOptions((prev) => ({
|
||||
...prev,
|
||||
[type]: val ? BooleanNumber.TRUE : BooleanNumber.FALSE,
|
||||
}));
|
||||
const linkedToPrevious = canLinkToPrevious && !sectionContext?.section?.[referenceKey];
|
||||
|
||||
const handleCheckboxChange = async (val: boolean, type: 'useFirstPageHeaderFooter' | 'evenAndOddHeaders') => {
|
||||
const sectionContext = getCurrentSectionContext();
|
||||
const documentStyle = sectionContext?.config;
|
||||
const docSkeletonManagerService = renderManagerService.getRenderUnitById(unitId)?.with(DocSkeletonManagerService);
|
||||
@@ -169,7 +173,7 @@ export function useHeaderFooterOptions(unitId: string) {
|
||||
docSelectionRenderService.setSegment(segmentId);
|
||||
}
|
||||
|
||||
commandService.executeCommand(CoreHeaderFooterCommandId, {
|
||||
await commandService.executeCommand(CoreHeaderFooterCommandId, {
|
||||
unitId,
|
||||
segmentId,
|
||||
headerFooterProps: {
|
||||
@@ -195,7 +199,7 @@ export function useHeaderFooterOptions(unitId: string) {
|
||||
docSelectionRenderService.setSegment(needFocusSegmentId);
|
||||
}
|
||||
|
||||
commandService.executeCommand(CoreHeaderFooterCommandId, {
|
||||
await commandService.executeCommand(CoreHeaderFooterCommandId, {
|
||||
unitId,
|
||||
headerFooterProps: {
|
||||
[type]: val ? BooleanNumber.TRUE : BooleanNumber.FALSE,
|
||||
@@ -208,11 +212,6 @@ export function useHeaderFooterOptions(unitId: string) {
|
||||
};
|
||||
|
||||
const handleMarginChange = async (val: number, type: 'marginHeader' | 'marginFooter') => {
|
||||
setOptions((prev) => ({
|
||||
...prev,
|
||||
[type]: val,
|
||||
}));
|
||||
|
||||
await commandService.executeCommand(CoreHeaderFooterCommandId, {
|
||||
unitId,
|
||||
headerFooterProps: {
|
||||
@@ -222,12 +221,11 @@ export function useHeaderFooterOptions(unitId: string) {
|
||||
});
|
||||
|
||||
// To make sure input always has focus.
|
||||
docSelectionRenderService.removeAllRanges();
|
||||
docSelectionRenderService.blur();
|
||||
};
|
||||
|
||||
const closeHeaderFooter = () => {
|
||||
commandService.executeCommand(CloseHeaderFooterCommand.id, {
|
||||
return commandService.executeCommand(CloseHeaderFooterCommand.id, {
|
||||
unitId,
|
||||
});
|
||||
};
|
||||
@@ -259,7 +257,6 @@ export function useHeaderFooterOptions(unitId: string) {
|
||||
if (!success) {
|
||||
return;
|
||||
}
|
||||
setLinkedToPrevious(linked);
|
||||
docSelectionRenderService.setSegment(linked ? previousSegmentId ?? '' : segmentId);
|
||||
layoutService.focus();
|
||||
};
|
||||
|
||||
@@ -14,8 +14,17 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { IDocumentBody } from '@univerjs/core';
|
||||
import type { IRichTextEditingMutationParams } from '../core-editing.mutation';
|
||||
import { CustomRangeType, DocumentFlavor, ICommandService, JSONX, TextX } from '@univerjs/core';
|
||||
import {
|
||||
CustomRangeType,
|
||||
DocumentFlavor,
|
||||
getRichTextEditPath,
|
||||
ICommandService,
|
||||
JSONX,
|
||||
TextX,
|
||||
Tools,
|
||||
} from '@univerjs/core';
|
||||
import { NORMAL_TEXT_SELECTION_PLUGIN_STYLE, registerDocumentLayoutPresentation } from '@univerjs/engine-render';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { createDocumentData, createTestBed } from '../../../facade/__tests__/create-test-bed';
|
||||
@@ -79,6 +88,59 @@ describe('transformDocumentTextRanges', () => {
|
||||
});
|
||||
|
||||
describe('RichTextEditingMutation selection scheduling', () => {
|
||||
it.each(['', 'header', 'footer'])('replays formatting and history consistently in initialized segment %j', (segmentId) => {
|
||||
const plainBody = (): IDocumentBody => ({
|
||||
dataStream: 'Plain text\r\n',
|
||||
textRuns: [],
|
||||
paragraphs: [{ paragraphId: 'plain', startIndex: 10 }],
|
||||
});
|
||||
const snapshot = createDocumentData('format-collab', plainBody());
|
||||
snapshot.headers = { header: { headerId: 'header', body: plainBody() } };
|
||||
snapshot.footers = { footer: { footerId: 'footer', body: plainBody() } };
|
||||
const local = createTestBed(Tools.deepClone(snapshot));
|
||||
const peer = createTestBed(Tools.deepClone(snapshot));
|
||||
try {
|
||||
const params: IRichTextEditingMutationParams = {
|
||||
unitId: snapshot.id,
|
||||
segmentId,
|
||||
textRanges: null,
|
||||
actions: JSONX.getInstance().editOp(new TextX().retain(6).retain(4, {
|
||||
dataStream: '',
|
||||
textRuns: [{ st: 0, ed: 4, ts: { bl: 1, fs: 20 } }],
|
||||
}).serialize(), getRichTextEditPath(local.doc, segmentId)),
|
||||
};
|
||||
const command = local.get(ICommandService);
|
||||
const undo = command.syncExecuteCommand<IRichTextEditingMutationParams, IRichTextEditingMutationParams>(
|
||||
RichTextEditingMutation.id,
|
||||
params
|
||||
);
|
||||
// The collaboration entity transmits actions without the local segment/selection metadata.
|
||||
peer.get(ICommandService).syncExecuteCommand(RichTextEditingMutation.id, {
|
||||
unitId: params.unitId,
|
||||
actions: JSON.parse(JSON.stringify(params.actions)),
|
||||
textRanges: null,
|
||||
}, { fromCollab: true });
|
||||
expect(local.doc.getSelfOrHeaderFooterModel(segmentId)?.getBody()?.textRuns)
|
||||
.toEqual([{ st: 6, ed: 10, ts: { bl: 1, fs: 20 } }]);
|
||||
expect(peer.doc.getSnapshot()).toEqual(local.doc.getSnapshot());
|
||||
const formatted = Tools.deepClone(local.doc.getSnapshot());
|
||||
const redo = command.syncExecuteCommand<IRichTextEditingMutationParams, IRichTextEditingMutationParams>(
|
||||
RichTextEditingMutation.id,
|
||||
undo
|
||||
);
|
||||
peer.get(ICommandService).syncExecuteCommand(RichTextEditingMutation.id, JSON.parse(JSON.stringify(undo)), { fromCollab: true });
|
||||
expect(local.doc.getSelfOrHeaderFooterModel(segmentId)?.getBody()?.textRuns).toEqual([]);
|
||||
expect(peer.doc.getSnapshot()).toEqual(local.doc.getSnapshot());
|
||||
command.syncExecuteCommand(RichTextEditingMutation.id, redo);
|
||||
peer.get(ICommandService).syncExecuteCommand(RichTextEditingMutation.id, JSON.parse(JSON.stringify(redo)), { fromCollab: true });
|
||||
expect(local.doc.getSnapshot()).toEqual(formatted);
|
||||
expect(peer.doc.getSnapshot()).toEqual(formatted);
|
||||
} finally {
|
||||
local.univer.dispose();
|
||||
peer.univer.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a locked keystroke without normalizing or modifying the imported snapshot', () => {
|
||||
const snapshot = createDocumentData('locked-input', {
|
||||
dataStream: 'ABCD\r\n',
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* 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, IDocumentData } from '@univerjs/core';
|
||||
import { CustomRangeType, ICommandService, IUniverInstanceService, Univer, UniverInstanceType } from '@univerjs/core';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { SetTextSelectionsOperation } from '../../commands/operations/text-selection.operation';
|
||||
import { DocSelectionManagerService } from '../../services/doc-selection-manager.service';
|
||||
import { DocCustomRangeController } from '../custom-range.controller';
|
||||
|
||||
let cleanup: (() => void) | undefined;
|
||||
afterEach(() => cleanup?.());
|
||||
|
||||
describe('DocCustomRangeController', () => {
|
||||
it('normalizes whole entities in the active story while leaving editable fields unchanged', () => {
|
||||
const univer = new Univer();
|
||||
cleanup = () => univer.dispose();
|
||||
const injector = univer.__getInjector();
|
||||
injector.add([DocSelectionManagerService]);
|
||||
injector.add([DocCustomRangeController]);
|
||||
const model = univer.createUnit<IDocumentData, DocumentDataModel>(UniverInstanceType.UNIVER_DOC, {
|
||||
id: 'story-selection',
|
||||
body: { dataStream: 'Body\r\n', customRanges: [{ startIndex: 0, endIndex: 3, rangeId: 'body-entity', rangeType: CustomRangeType.CUSTOM, wholeEntity: true }] },
|
||||
headers: { header: { headerId: 'header', body: { dataStream: '\u001FAB\u001E \u001FCD\u001E\r\n', customRanges: [
|
||||
{ startIndex: 0, endIndex: 3, rangeId: 'header-entity', rangeType: CustomRangeType.CUSTOM, wholeEntity: true },
|
||||
{ startIndex: 5, endIndex: 8, rangeId: 'editable-field', rangeType: CustomRangeType.FIELD, wholeEntity: false },
|
||||
] } } },
|
||||
});
|
||||
injector.get(IUniverInstanceService).focusUnit(model.getUnitId());
|
||||
const selections = injector.get(DocSelectionManagerService);
|
||||
injector.get(DocCustomRangeController);
|
||||
const commands = injector.get(ICommandService);
|
||||
commands.registerCommand(SetTextSelectionsOperation);
|
||||
let transformed: unknown;
|
||||
const subscription = selections.refreshSelection$.subscribe((value) => transformed = value?.docRanges);
|
||||
commands.syncExecuteCommand(SetTextSelectionsOperation.id, {
|
||||
unitId: model.getUnitId(),
|
||||
segmentId: 'header',
|
||||
ranges: [{ startOffset: 2, endOffset: 2, collapsed: true }],
|
||||
});
|
||||
expect(transformed).toEqual([expect.objectContaining({ startOffset: 0, endOffset: 4, collapsed: false })]);
|
||||
transformed = undefined;
|
||||
commands.syncExecuteCommand(SetTextSelectionsOperation.id, {
|
||||
unitId: model.getUnitId(),
|
||||
segmentId: 'header',
|
||||
ranges: [{ startOffset: 6, endOffset: 6, collapsed: true }],
|
||||
});
|
||||
expect(transformed).toBeUndefined();
|
||||
commands.syncExecuteCommand(SetTextSelectionsOperation.id, {
|
||||
unitId: model.getUnitId(),
|
||||
segmentId: 'header',
|
||||
ranges: [{ startOffset: 6, endOffset: 8, collapsed: false }],
|
||||
});
|
||||
expect(transformed).toEqual([expect.objectContaining({ startOffset: 5, endOffset: 9, collapsed: false })]);
|
||||
subscription.unsubscribe();
|
||||
});
|
||||
});
|
||||
@@ -17,7 +17,14 @@
|
||||
import type { DocumentDataModel } from '@univerjs/core';
|
||||
import type { ITextRangeWithStyle } from '@univerjs/engine-render';
|
||||
import type { ISetTextSelectionsOperationParams } from '../commands/operations/text-selection.operation';
|
||||
import { BuildTextUtils, Disposable, ICommandService, Inject, IUniverInstanceService } from '@univerjs/core';
|
||||
import {
|
||||
BuildTextUtils,
|
||||
CustomRangeType,
|
||||
Disposable,
|
||||
ICommandService,
|
||||
Inject,
|
||||
IUniverInstanceService,
|
||||
} from '@univerjs/core';
|
||||
import { SetTextSelectionsOperation } from '../commands/operations/text-selection.operation';
|
||||
import { DocSelectionManagerService } from '../services/doc-selection-manager.service';
|
||||
|
||||
@@ -32,11 +39,13 @@ export class DocCustomRangeController extends Disposable {
|
||||
this._initSelectionChange();
|
||||
}
|
||||
|
||||
private _transformCustomRange(doc: DocumentDataModel, selection: ITextRangeWithStyle) {
|
||||
private _transformCustomRange(doc: DocumentDataModel, selection: ITextRangeWithStyle, segmentId?: string) {
|
||||
const { startOffset, endOffset, collapsed } = selection;
|
||||
// find ranges intersect but not contain with selection
|
||||
const customRanges = doc.getCustomRanges()?.filter((range) => {
|
||||
if (!range.wholeEntity) {
|
||||
const customRanges = doc.getSelfOrHeaderFooterModel(selection.segmentId ?? segmentId)?.getBody()?.customRanges?.filter((range) => {
|
||||
const selectsFieldResult = range.rangeType === CustomRangeType.FIELD && !collapsed &&
|
||||
startOffset <= range.startIndex + 1 && endOffset >= range.endIndex;
|
||||
if (!range.wholeEntity && !selectsFieldResult) {
|
||||
return false;
|
||||
}
|
||||
if (startOffset <= range.startIndex && endOffset > range.endIndex) {
|
||||
@@ -77,7 +86,7 @@ export class DocCustomRangeController extends Disposable {
|
||||
if (!doc) {
|
||||
return;
|
||||
}
|
||||
const transformedRanges = ranges.map((range) => this._transformCustomRange(doc, range));
|
||||
const transformedRanges = ranges.map((range) => this._transformCustomRange(doc, range, params.segmentId));
|
||||
if (transformedRanges.some((range, i) => ranges[i] !== range)) {
|
||||
this._textSelectionManagerService.replaceDocRanges(transformedRanges, undefined, isEditing);
|
||||
}
|
||||
|
||||
@@ -45,7 +45,6 @@ import {
|
||||
DocLayoutExecutorState,
|
||||
DocLayoutExecutorType,
|
||||
DocLayoutSessionStatus,
|
||||
|
||||
} from '../doc-layout-executor.service';
|
||||
|
||||
function createDocumentData(id: string, documentFlavor: DocumentFlavor): IDocumentData {
|
||||
@@ -715,7 +714,21 @@ describe('DocLayoutExecutorService', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(['body', 'header', 'footer'] as const)('serializes main-thread custom block viewport measurements from the %s with a layout start', async (story) => {
|
||||
it.each([
|
||||
{ story: 'body', flavor: DocumentFlavor.TRADITIONAL, options: {}, margin: 50, pageWidth: 594 },
|
||||
{ story: 'header', flavor: DocumentFlavor.TRADITIONAL, options: {}, margin: 50, pageWidth: 594 },
|
||||
{ story: 'footer', flavor: DocumentFlavor.TRADITIONAL, options: {}, margin: 50, pageWidth: 594 },
|
||||
{ story: 'body', flavor: DocumentFlavor.MODERN, options: {}, margin: 50 / 0.75, pageWidth: 594 },
|
||||
{ story: 'header', flavor: DocumentFlavor.MODERN, options: {}, margin: 50 / 0.75, pageWidth: 594 },
|
||||
{ story: 'footer', flavor: DocumentFlavor.MODERN, options: {}, margin: 50 / 0.75, pageWidth: 594 },
|
||||
{
|
||||
story: 'body',
|
||||
flavor: DocumentFlavor.MODERN,
|
||||
options: { modernPageWidth: 720, modernHorizontalMargin: 24 },
|
||||
margin: 24,
|
||||
pageWidth: 720,
|
||||
},
|
||||
])('serializes custom block measurements from the $story with flavor $flavor and margin $margin', async ({ story, flavor, options, margin, pageWidth }) => {
|
||||
const injector = univer.__getInjector();
|
||||
const service = injector.get(DocLayoutExecutorService);
|
||||
const executor = createExecutor();
|
||||
@@ -726,6 +739,11 @@ describe('DocLayoutExecutorService', () => {
|
||||
if (!viewportEnabled || unitId !== 'traditional-doc' || blockId !== 'embed-1') {
|
||||
return null;
|
||||
}
|
||||
expect(input).toEqual(expect.objectContaining({
|
||||
pageMarginLeft: margin,
|
||||
pageMarginRight: margin,
|
||||
pageWidth,
|
||||
}));
|
||||
return {
|
||||
width: input.fallbackWidth + extraWidth,
|
||||
height: input.fallbackHeight + 20,
|
||||
@@ -733,7 +751,9 @@ describe('DocLayoutExecutorService', () => {
|
||||
contentHeight: 480,
|
||||
};
|
||||
});
|
||||
const documentData = createDocumentData('traditional-doc', DocumentFlavor.TRADITIONAL);
|
||||
const documentData = createDocumentData('traditional-doc', flavor);
|
||||
documentData.documentStyle.marginLeft = 50;
|
||||
documentData.documentStyle.marginRight = 50;
|
||||
documentData.body = {
|
||||
dataStream: '\b\r\n',
|
||||
paragraphs: [{ paragraphId: 'paragraph-1', startIndex: 1 }],
|
||||
@@ -759,7 +779,7 @@ describe('DocLayoutExecutorService', () => {
|
||||
};
|
||||
if (story !== 'body') {
|
||||
const body = documentData.body;
|
||||
documentData.body = createDocumentData('traditional-doc', DocumentFlavor.TRADITIONAL).body;
|
||||
documentData.body = createDocumentData('traditional-doc', flavor).body;
|
||||
if (story === 'header') {
|
||||
documentData.headers = { header: { headerId: 'header', body } };
|
||||
documentData.documentStyle.defaultHeaderId = 'header';
|
||||
@@ -771,7 +791,7 @@ describe('DocLayoutExecutorService', () => {
|
||||
univer.createUnit<IDocumentData, DocumentDataModel>(UniverInstanceType.UNIVER_DOC, documentData);
|
||||
await Promise.resolve();
|
||||
|
||||
await service.startLayout(createMountIdentity(), { reason: 'initial' }, 32);
|
||||
await service.startLayout(createMountIdentity(), { reason: 'initial', ...options }, 32);
|
||||
|
||||
expect(executor.startLayout).toHaveBeenCalledWith(expect.objectContaining({
|
||||
customBlockViewports: {
|
||||
@@ -784,12 +804,12 @@ describe('DocLayoutExecutorService', () => {
|
||||
},
|
||||
}));
|
||||
|
||||
await service.startLayout(createMountIdentity(), { reason: 'edit' }, 32);
|
||||
await service.startLayout(createMountIdentity(), { reason: 'edit', ...options }, 32);
|
||||
expect(vi.mocked(executor.startLayout).mock.lastCall?.[0]).not.toHaveProperty('customBlockViewportPatch');
|
||||
expect(vi.mocked(executor.startLayout).mock.lastCall?.[0]).not.toHaveProperty('customBlockViewports');
|
||||
|
||||
extraWidth = 20;
|
||||
await service.startLayout(createMountIdentity(), { reason: 'edit' }, 32);
|
||||
await service.startLayout(createMountIdentity(), { reason: 'edit', ...options }, 32);
|
||||
expect(vi.mocked(executor.startLayout).mock.lastCall?.[0]).toEqual(expect.objectContaining({
|
||||
customBlockViewportPatch: {
|
||||
removals: [],
|
||||
@@ -805,7 +825,7 @@ describe('DocLayoutExecutorService', () => {
|
||||
}));
|
||||
|
||||
viewportEnabled = false;
|
||||
await service.startLayout(createMountIdentity(), { reason: 'edit' }, 32);
|
||||
await service.startLayout(createMountIdentity(), { reason: 'edit', ...options }, 32);
|
||||
expect(vi.mocked(executor.startLayout).mock.lastCall?.[0]).toEqual(expect.objectContaining({
|
||||
customBlockViewportPatch: {
|
||||
removals: ['embed-1'],
|
||||
|
||||
@@ -34,18 +34,17 @@ import type {
|
||||
import type { IRichTextEditingMutationParams } from '../commands/mutations/core-editing.mutation';
|
||||
import {
|
||||
Disposable,
|
||||
|
||||
DocumentFlavor,
|
||||
ICommandService,
|
||||
|
||||
ILogService,
|
||||
Inject,
|
||||
isInternalEditorID,
|
||||
IUniverInstanceService,
|
||||
JSONX,
|
||||
|
||||
LocaleService,
|
||||
|
||||
MODERN_DOCUMENT_DEFAULT_MARGIN,
|
||||
MODERN_DOCUMENT_WIDTH,
|
||||
ModernDocumentWidthMode,
|
||||
requestImmediateMacroTask,
|
||||
toDisposable,
|
||||
UniverInstanceType,
|
||||
@@ -328,9 +327,19 @@ function isRichTextEditingMutationParams(value: unknown): value is IRichTextEdit
|
||||
'actions' in value;
|
||||
}
|
||||
|
||||
function collectCustomBlockViewports(dataModel: DocumentDataModel): Record<string, IDocsCustomBlockRenderViewport> {
|
||||
function collectCustomBlockViewports(
|
||||
dataModel: DocumentDataModel,
|
||||
options: IDocLayoutStartOptions
|
||||
): Record<string, IDocsCustomBlockRenderViewport> {
|
||||
const snapshot = dataModel.getSnapshot();
|
||||
const documentStyle = snapshot.documentStyle;
|
||||
const isModern = documentStyle.documentFlavor === DocumentFlavor.MODERN;
|
||||
const modernMargin = options.modernHorizontalMargin ?? MODERN_DOCUMENT_DEFAULT_MARGIN;
|
||||
const pageMarginLeft = isModern ? modernMargin : documentStyle.marginLeft;
|
||||
const pageMarginRight = isModern ? modernMargin : documentStyle.marginRight;
|
||||
const pageWidth = isModern
|
||||
? options.modernPageWidth ?? documentStyle.pageSize?.width ?? MODERN_DOCUMENT_WIDTH[ModernDocumentWidthMode.MEDIUM]
|
||||
: documentStyle.pageSize?.width;
|
||||
const viewports: Record<string, IDocsCustomBlockRenderViewport> = {};
|
||||
|
||||
const bodies = [
|
||||
@@ -347,9 +356,9 @@ function collectCustomBlockViewports(dataModel: DocumentDataModel): Record<strin
|
||||
const viewport = getDocsCustomBlockRenderViewport(dataModel.getUnitId(), customBlock.blockId, {
|
||||
fallbackHeight: drawing.docTransform.size.height ?? 0,
|
||||
fallbackWidth: drawing.docTransform.size.width ?? 0,
|
||||
pageMarginLeft: documentStyle.marginLeft,
|
||||
pageMarginRight: documentStyle.marginRight,
|
||||
pageWidth: documentStyle.pageSize?.width,
|
||||
pageMarginLeft,
|
||||
pageMarginRight,
|
||||
pageWidth,
|
||||
});
|
||||
if (viewport != null) {
|
||||
viewports[customBlock.blockId] = { ...viewport };
|
||||
@@ -753,7 +762,8 @@ export class DocLayoutExecutorService extends Disposable {
|
||||
const projections = this._createLayoutProjectionPayload(
|
||||
session,
|
||||
identity,
|
||||
this._getRequiredModel(unitId)
|
||||
this._getRequiredModel(unitId),
|
||||
options
|
||||
);
|
||||
const request: IDocLayoutStartRequest = {
|
||||
...identity,
|
||||
@@ -795,9 +805,10 @@ export class DocLayoutExecutorService extends Disposable {
|
||||
private _createLayoutProjectionPayload(
|
||||
session: IDocLayoutManagedSession,
|
||||
identity: IDocLayoutMountIdentity,
|
||||
dataModel: DocumentDataModel
|
||||
dataModel: DocumentDataModel,
|
||||
options: IDocLayoutStartOptions
|
||||
): IDocLayoutProjectionPayload {
|
||||
const customBlockViewports = collectCustomBlockViewports(dataModel);
|
||||
const customBlockViewports = collectCustomBlockViewports(dataModel, options);
|
||||
const previousCustomBlockViewports = session.customBlockViewportsByMount.get(identity.mountId);
|
||||
const customRangePresentations = this._collectCustomRangePresentations(dataModel);
|
||||
const customRangePresentationMap = toCustomRangePresentationMap(customRangePresentations);
|
||||
|
||||
@@ -212,16 +212,23 @@ function ToolbarButton(props: {
|
||||
children: ReactNode;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const [tooltipVisible, setTooltipVisible] = useState(false);
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
className="univer-fill-mode-backwards univer-delay-100"
|
||||
title={props.title}
|
||||
placement="bottom"
|
||||
visible={tooltipVisible}
|
||||
onVisibleChange={setTooltipVisible}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
disabled={props.disabled}
|
||||
onClick={props.onClick}
|
||||
onClick={() => {
|
||||
setTooltipVisible(false);
|
||||
props.onClick();
|
||||
}}
|
||||
className={clsx(`
|
||||
univer-flex univer-h-6 univer-w-6 univer-items-center univer-justify-center univer-rounded-md
|
||||
univer-border-none univer-bg-transparent univer-p-0 univer-text-sm univer-text-gray-700
|
||||
@@ -247,12 +254,18 @@ function ToolbarDropdownButton<T extends string | number>(props: {
|
||||
onChange: (value: T) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [tooltipVisible, setTooltipVisible] = useState(false);
|
||||
const activeOption = props.options.find((option) => option.value === props.value) ?? props.options[0];
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
onOpenChange={(open) => {
|
||||
setOpen(open);
|
||||
if (open) {
|
||||
setTooltipVisible(false);
|
||||
}
|
||||
}}
|
||||
overlay={(
|
||||
<div
|
||||
className={`
|
||||
@@ -292,6 +305,8 @@ function ToolbarDropdownButton<T extends string | number>(props: {
|
||||
className="univer-fill-mode-backwards univer-delay-100"
|
||||
title={props.title}
|
||||
placement="bottom"
|
||||
visible={tooltipVisible && !open}
|
||||
onVisibleChange={(visible) => setTooltipVisible(visible && !open)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
|
||||
import { CommandType, ICommandService, LocaleService, LocaleType, Univer } from '@univerjs/core';
|
||||
import { IconManager, RediContext } from '@univerjs/ui';
|
||||
import { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { afterEach, expect, it } from 'vitest';
|
||||
import { ImagePopupMenu } from '../ImagePopupMenu';
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
const cleanups: Array<() => void> = [];
|
||||
|
||||
afterEach(async () => {
|
||||
cleanups.splice(0).reverse().forEach((dispose) => dispose());
|
||||
await act(async () => new Promise((resolve) => setTimeout(resolve, 0)));
|
||||
});
|
||||
|
||||
it('dismisses the chart type tooltip while the dropdown is open and routes the selected type', async () => {
|
||||
const univer = new Univer();
|
||||
cleanups.push(() => univer.dispose());
|
||||
const injector = univer.__getInjector();
|
||||
injector.add([IconManager]);
|
||||
const locale = injector.get(LocaleService);
|
||||
locale.load({ [LocaleType.EN_US]: { test: { chartType: 'Chart type' } } });
|
||||
locale.setLocale(LocaleType.EN_US);
|
||||
locale.setDirection('ltr');
|
||||
const selected: string[] = [];
|
||||
injector.get(ICommandService).registerCommand({
|
||||
id: 'test.operation.chart-type',
|
||||
type: CommandType.OPERATION,
|
||||
handler: (_accessor, params: { type: string }) => {
|
||||
selected.push(params.type);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
const container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
cleanups.push(() => container.remove());
|
||||
const root = createRoot(container);
|
||||
cleanups.push(() => act(() => root.unmount()));
|
||||
await act(async () => root.render(
|
||||
<RediContext.Provider value={{ injector }}>
|
||||
<ImagePopupMenu
|
||||
popup={{ extraProps: {
|
||||
variant: 'doc-chart-floating-toolbar',
|
||||
menuItems: [{
|
||||
type: 'select',
|
||||
label: 'test.chartType',
|
||||
index: 0,
|
||||
commandId: 'test.operation.chart-type',
|
||||
disable: false,
|
||||
value: 'column',
|
||||
options: [{ value: 'column', label: 'Column chart' }, { value: 'line', label: 'Line chart' }],
|
||||
commandParamsFactory: (type) => ({ type }),
|
||||
}],
|
||||
} }}
|
||||
/>
|
||||
</RediContext.Provider>
|
||||
));
|
||||
const trigger = container.querySelector<HTMLButtonElement>('button')!;
|
||||
const hoverTarget = trigger.parentElement!;
|
||||
await act(async () => hoverTarget.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })));
|
||||
expect(document.querySelector('[role="tooltip"]')?.textContent).toBe('Chart type');
|
||||
await act(async () => trigger.click());
|
||||
expect(document.querySelector('[role="tooltip"]')).toBeNull();
|
||||
await act(async () => hoverTarget.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })));
|
||||
expect(document.querySelector('[role="tooltip"]')).toBeNull();
|
||||
const option = Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find((button) => button.textContent === 'Line chart')!;
|
||||
expect(option).toBeDefined();
|
||||
await act(async () => option.click());
|
||||
expect(selected).toEqual(['line']);
|
||||
expect(option.isConnected).toBe(false);
|
||||
expect(document.querySelector('[role="tooltip"]')).toBeNull();
|
||||
});
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
import type { IExecutionOptions, IMutation, IUnitRange, Nullable } from '@univerjs/core';
|
||||
import type {
|
||||
IFormulaData,
|
||||
IFormulaExecuteResultMap,
|
||||
IFormulaStringMap,
|
||||
IRuntimeOtherUnitDataType,
|
||||
@@ -128,6 +129,7 @@ export const SetFormulaCalculationNotificationMutation: IMutation<ISetFormulaCal
|
||||
|
||||
export interface ISetFormulaCalculationResultMutation {
|
||||
unitData: IRuntimeUnitDataPrimitiveType;
|
||||
sourceFormulaData?: IFormulaData;
|
||||
unitOtherData: IRuntimeOtherUnitDataType;
|
||||
}
|
||||
|
||||
|
||||
@@ -289,6 +289,7 @@ export class CalculateController extends Disposable {
|
||||
private async _applyResult(data: IAllRuntimeData) {
|
||||
const {
|
||||
unitData,
|
||||
sourceFormulaData,
|
||||
unitOtherData,
|
||||
arrayFormulaRange,
|
||||
arrayFormulaCellData,
|
||||
@@ -340,6 +341,7 @@ export class CalculateController extends Disposable {
|
||||
SetFormulaCalculationResultMutation.id,
|
||||
{
|
||||
unitData: convertRuntimeToUnitData(unitData),
|
||||
sourceFormulaData,
|
||||
unitOtherData,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -14,7 +14,10 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { IConfigService, Injector } from '@univerjs/core';
|
||||
import type { ICellData } from '@univerjs/core';
|
||||
import type { IFormulaDatasetConfig } from '../../basics/common';
|
||||
import type { IAllRuntimeData } from '../runtime.service';
|
||||
import { IConfigService, Injector, ObjectMatrix } from '@univerjs/core';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ErrorType } from '../../basics/error-type';
|
||||
import { ENGINE_FORMULA_PLUGIN_CONFIG_KEY } from '../../config/config';
|
||||
@@ -23,8 +26,13 @@ import { AstTreeBuilder } from '../../engine/analysis/parser';
|
||||
import { IFormulaDependencyGenerator } from '../../engine/dependency/formula-dependency';
|
||||
import { Interpreter } from '../../engine/interpreter/interpreter';
|
||||
import { FORMULA_REF_TO_ARRAY_CACHE } from '../../engine/reference-object/base-reference-object';
|
||||
import { createFunctionTestBed } from '../../functions/__tests__/create-function-test-bed';
|
||||
import { AsyncCustomFunction } from '../../functions/custom-function';
|
||||
import { CalculateFormulaService, ICalculateFormulaService } from '../calculate-formula.service';
|
||||
import { IFormulaCurrentConfigService } from '../current-data.service';
|
||||
import { DependencyManagerService, IDependencyManagerService } from '../dependency-manager.service';
|
||||
import { FeatureCalculationManagerService, IFeatureCalculationManagerService } from '../feature-calculation-manager.service';
|
||||
import { IFunctionService } from '../function.service';
|
||||
import { FormulaExecutedStateType, FormulaExecuteStageType, IFormulaRuntimeService } from '../runtime.service';
|
||||
|
||||
function createService() {
|
||||
@@ -204,6 +212,145 @@ describe('CalculateFormulaService', () => {
|
||||
FORMULA_REF_TO_ARRAY_CACHE.clear();
|
||||
});
|
||||
|
||||
it('retains a queued scalar source when a preceding asynchronous dependency allows its cell to be cleared', async () => {
|
||||
const testBed = createFunctionTestBed(undefined, [
|
||||
[IFeatureCalculationManagerService, { useClass: FeatureCalculationManagerService }],
|
||||
[IDependencyManagerService, { useClass: DependencyManagerService }],
|
||||
]);
|
||||
const formula = '=WAITFORVALUE(A1)';
|
||||
const cells = new ObjectMatrix<ICellData>({ 0: { 0: { f: '=WAITFORVALUE()' }, 2: { f: formula } } });
|
||||
let releaseResult: (value: number) => void = () => {};
|
||||
let markStarted: () => void = () => {};
|
||||
const delayedValue = new Promise<number>((resolve) => {
|
||||
releaseResult = resolve;
|
||||
});
|
||||
const started = new Promise<void>((resolve) => {
|
||||
markStarted = resolve;
|
||||
});
|
||||
const executor = new AsyncCustomFunction('WAITFORVALUE');
|
||||
executor.calculateCustom = () => {
|
||||
markStarted();
|
||||
return delayedValue;
|
||||
};
|
||||
testBed.get(IFunctionService).registerExecutors(executor);
|
||||
const service = testBed.get(ICalculateFormulaService);
|
||||
let markCompleted: (data: IAllRuntimeData) => void = () => {};
|
||||
const completed = new Promise<IAllRuntimeData>((resolve) => {
|
||||
markCompleted = resolve;
|
||||
});
|
||||
const subscription = service.executionCompleteListener$.subscribe(markCompleted);
|
||||
try {
|
||||
const execution = service.execute({
|
||||
allUnitData: { [testBed.unitId]: { [testBed.sheetId]: {
|
||||
...testBed.sheetData[testBed.sheetId],
|
||||
cellData: cells,
|
||||
} } },
|
||||
unitSheetNameMap: { [testBed.unitId]: { [testBed.sheetId]: 'Sheet1' } },
|
||||
unitStylesData: {},
|
||||
formulaData: { [testBed.unitId]: { [testBed.sheetId]: { 0: {
|
||||
0: { f: '=WAITFORVALUE()' },
|
||||
2: { f: formula },
|
||||
} } } },
|
||||
arrayFormulaCellData: {},
|
||||
arrayFormulaRange: {},
|
||||
forceCalculate: true,
|
||||
dirtyRanges: [],
|
||||
dirtyNameMap: {},
|
||||
dirtyDefinedNameMap: {},
|
||||
dirtyUnitFeatureMap: {},
|
||||
dirtyUnitOtherFormulaMap: {},
|
||||
});
|
||||
await started;
|
||||
cells.realDeleteValue(0, 2);
|
||||
releaseResult(9);
|
||||
const [result] = await Promise.all([completed, execution]);
|
||||
expect(result.unitData[testBed.unitId]?.[testBed.sheetId]?.getValue(0, 2)?.v).toBe(9);
|
||||
expect(result.sourceFormulaData?.[testBed.unitId]?.[testBed.sheetId]?.[0]?.[2]?.f).toBe(formula);
|
||||
} finally {
|
||||
releaseResult(9);
|
||||
subscription.unsubscribe();
|
||||
testBed.univer.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it('retains an in-flight scalar formula source when its replacement is queued', async () => {
|
||||
const testBed = createFunctionTestBed(undefined, [
|
||||
[IFeatureCalculationManagerService, { useClass: FeatureCalculationManagerService }],
|
||||
[IDependencyManagerService, { useClass: DependencyManagerService }],
|
||||
]);
|
||||
const formula = '=WAITFORVALUE()';
|
||||
let releaseResult: (value: number) => void = () => {};
|
||||
let markStarted: () => void = () => {};
|
||||
const delayedValue = new Promise<number>((resolve) => {
|
||||
releaseResult = resolve;
|
||||
});
|
||||
const started = new Promise<void>((resolve) => {
|
||||
markStarted = resolve;
|
||||
});
|
||||
const executor = new AsyncCustomFunction('WAITFORVALUE');
|
||||
executor.calculateCustom = () => {
|
||||
markStarted();
|
||||
return delayedValue;
|
||||
};
|
||||
testBed.get(IFunctionService).registerExecutors(executor);
|
||||
const service = testBed.get(ICalculateFormulaService);
|
||||
const results: IAllRuntimeData[] = [];
|
||||
let markCompleted: () => void = () => {};
|
||||
const completed = new Promise<void>((resolve) => {
|
||||
markCompleted = resolve;
|
||||
});
|
||||
const subscription = service.executionCompleteListener$.subscribe((data) => {
|
||||
results.push(data);
|
||||
if (results.length === 2) {
|
||||
markCompleted();
|
||||
}
|
||||
});
|
||||
const config: IFormulaDatasetConfig = {
|
||||
allUnitData: { [testBed.unitId]: { [testBed.sheetId]: {
|
||||
...testBed.sheetData[testBed.sheetId],
|
||||
cellData: new ObjectMatrix<ICellData>({ 0: { 2: { f: formula } } }),
|
||||
} } },
|
||||
unitSheetNameMap: { [testBed.unitId]: { Sheet1: testBed.sheetId } },
|
||||
unitStylesData: {},
|
||||
formulaData: { [testBed.unitId]: { [testBed.sheetId]: { 0: { 2: { f: formula } } } } },
|
||||
arrayFormulaCellData: {},
|
||||
arrayFormulaRange: {},
|
||||
forceCalculate: true,
|
||||
dirtyRanges: [],
|
||||
dirtyNameMap: {},
|
||||
dirtyDefinedNameMap: {},
|
||||
dirtyUnitFeatureMap: {},
|
||||
dirtyUnitOtherFormulaMap: {},
|
||||
};
|
||||
try {
|
||||
const firstExecution = service.execute(config);
|
||||
await started;
|
||||
const replacementExecution = service.execute({
|
||||
...config,
|
||||
formulaData: {},
|
||||
allUnitData: { [testBed.unitId]: { [testBed.sheetId]: {
|
||||
...testBed.sheetData[testBed.sheetId],
|
||||
cellData: new ObjectMatrix<ICellData>({ 0: { 2: { v: 42 } } }),
|
||||
} } },
|
||||
});
|
||||
const pendingFormula = testBed.get(IFormulaCurrentConfigService)
|
||||
.getUnitData()[testBed.unitId]?.[testBed.sheetId]
|
||||
?.cellData
|
||||
.getValue(0, 2)
|
||||
?.f;
|
||||
releaseResult(10);
|
||||
await Promise.all([firstExecution, replacementExecution, completed]);
|
||||
expect(pendingFormula).toBe(formula);
|
||||
expect(results[0].unitData[testBed.unitId]?.[testBed.sheetId]?.getValue(0, 2)?.v).toBe(10);
|
||||
expect(results[0].sourceFormulaData?.[testBed.unitId]?.[testBed.sheetId]?.[0]?.[2]?.f).toBe(formula);
|
||||
expect(results[1].unitData[testBed.unitId]?.[testBed.sheetId]?.getValue(0, 2)).toBeUndefined();
|
||||
} finally {
|
||||
releaseResult(10);
|
||||
subscription.unsubscribe();
|
||||
testBed.univer.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it('should forward stop and feature runtime setters', () => {
|
||||
const { service, mocks } = createService();
|
||||
|
||||
|
||||
@@ -96,6 +96,39 @@ function createRuntimeService() {
|
||||
}
|
||||
|
||||
describe('FormulaRuntimeService', () => {
|
||||
it('keeps the scalar result source from before an asynchronous formula replacement and clears it on reset', () => {
|
||||
const { injector, runtime, unitDataMatrix } = createRuntimeService();
|
||||
try {
|
||||
unitDataMatrix.setValue(2, 3, { f: '=SUM(A1:B2)' });
|
||||
runtime.setCurrent(2, 3, 20, 20, 'sheet', 'unit');
|
||||
unitDataMatrix.setValue(2, 3, { f: '=42' });
|
||||
runtime.setRuntimeData(NumberValueObject.create(10));
|
||||
|
||||
expect(runtime.getAllRuntimeData().sourceFormulaData?.unit?.sheet?.[2]?.[3]?.f).toBe('=SUM(A1:B2)');
|
||||
runtime.reset();
|
||||
expect(runtime.getAllRuntimeData().sourceFormulaData).toEqual({});
|
||||
} finally {
|
||||
injector.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ source: { f: '=SUM(A1:B2)', si: 'shared' }, expected: { f: '=SUM(A1:B2)', si: 'shared' } },
|
||||
{ source: { f: '=SUM(A1:B2)', si: 'shared', x: 1, y: 0 }, expected: { f: '', si: 'shared' } },
|
||||
])('keeps the original shared formula identity when its live cell is replaced: $source', ({ source, expected }) => {
|
||||
const { injector, runtime, unitDataMatrix } = createRuntimeService();
|
||||
try {
|
||||
injector.get(IFormulaCurrentConfigService).registerFormulaData({ unit: { sheet: { 2: { 3: source } } } });
|
||||
unitDataMatrix.setValue(2, 3, { f: '=42', v: 42 });
|
||||
runtime.setCurrent(2, 3, 20, 20, 'sheet', 'unit');
|
||||
runtime.setRuntimeData(NumberValueObject.create(10));
|
||||
|
||||
expect(runtime.getAllRuntimeData().sourceFormulaData?.unit?.sheet?.[2]?.[3]).toEqual(expected);
|
||||
} finally {
|
||||
injector.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it('indexes scalar and spilled null results as blanks, consistently with reference scans', () => {
|
||||
const { injector, runtime } = createRuntimeService();
|
||||
CELL_INVERTED_INDEX_CACHE.clear();
|
||||
|
||||
@@ -143,18 +143,18 @@ export class CalculateFormulaService extends Disposable implements ICalculateFor
|
||||
}
|
||||
|
||||
async execute(formulaDatasetConfig: IFormulaDatasetConfig) {
|
||||
this._runtimeService.setFormulaExecuteStage(FormulaExecuteStageType.START);
|
||||
this._executionInProgressListener$.next(this._runtimeService.getRuntimeState());
|
||||
return this._executeLock.acquire('FORMULA_EXECUTION_LOCK', async () => {
|
||||
this._runtimeService.setFormulaExecuteStage(FormulaExecuteStageType.START);
|
||||
this._executionInProgressListener$.next(this._runtimeService.getRuntimeState());
|
||||
|
||||
this._currentConfigService.load(formulaDatasetConfig);
|
||||
this._currentConfigService.load(formulaDatasetConfig);
|
||||
|
||||
this._runtimeService.reset();
|
||||
this._runtimeService.reset();
|
||||
|
||||
const cycleReferenceCount = (formulaDatasetConfig.maxIteration || DEFAULT_CYCLE_REFERENCE_COUNT) as number;
|
||||
const cycleReferenceCount = (formulaDatasetConfig.maxIteration || DEFAULT_CYCLE_REFERENCE_COUNT) as number;
|
||||
|
||||
this._isCalculateTreeModel = formulaDatasetConfig.isCalculateTreeModel || false;
|
||||
this._isCalculateTreeModel = formulaDatasetConfig.isCalculateTreeModel || false;
|
||||
|
||||
this._executeLock.acquire('FORMULA_EXECUTION_LOCK', async () => {
|
||||
for (let i = 0; i < cycleReferenceCount; i++) {
|
||||
this._runtimeService.setFormulaCycleIndex(i);
|
||||
const executed = await this._executeStep();
|
||||
|
||||
@@ -19,6 +19,8 @@ import type {
|
||||
IArrayFormulaEmbeddedMap,
|
||||
IArrayFormulaRangeType,
|
||||
IFeatureDirtyRangeType,
|
||||
IFormulaData,
|
||||
IFormulaDataItem,
|
||||
IRuntimeImageFormulaDataType,
|
||||
IRuntimeOtherUnitDataType,
|
||||
IRuntimeUnitDataType,
|
||||
@@ -68,6 +70,7 @@ export enum FormulaExecutedStateType {
|
||||
|
||||
export interface IAllRuntimeData {
|
||||
unitData: IRuntimeUnitDataType;
|
||||
sourceFormulaData?: IFormulaData;
|
||||
arrayFormulaRange: IArrayFormulaRangeType;
|
||||
arrayFormulaEmbedded: IArrayFormulaEmbeddedMap;
|
||||
unitOtherData: IRuntimeOtherUnitDataType;
|
||||
@@ -227,6 +230,8 @@ export class FormulaRuntimeService extends Disposable implements IFormulaRuntime
|
||||
private _currentUnitId: string = '';
|
||||
|
||||
private _runtimeData: IRuntimeUnitDataType = Object.create(null);
|
||||
private _sourceFormulaData: IFormulaData = Object.create(null);
|
||||
private _currentSourceFormula: IFormulaDataItem | undefined;
|
||||
|
||||
private _runtimeOtherData: IRuntimeOtherUnitDataType = Object.create(null); // Data returned by other businesses through formula calculation, excluding the sheet.
|
||||
|
||||
@@ -394,6 +399,8 @@ export class FormulaRuntimeService extends Disposable implements IFormulaRuntime
|
||||
reset() {
|
||||
this._formulaExecuteStage = FormulaExecuteStageType.IDLE;
|
||||
this._runtimeData = Object.create(null);
|
||||
this._sourceFormulaData = Object.create(null);
|
||||
this._currentSourceFormula = undefined;
|
||||
this._runtimeOtherData = Object.create(null);
|
||||
this._unitArrayFormulaRange = Object.create(null);
|
||||
this._unitArrayFormulaEmbeddedMap = Object.create(null);
|
||||
@@ -436,6 +443,15 @@ export class FormulaRuntimeService extends Disposable implements IFormulaRuntime
|
||||
this._currentColumnCount = columnCount;
|
||||
this._currentSubUnitId = sheetId;
|
||||
this._currentUnitId = unitId;
|
||||
const cell = this._currentConfigService.getUnitData()[unitId]?.[sheetId]?.cellData.getValue(row, column);
|
||||
const formula = this._currentConfigService.getFormulaData()[unitId]?.[sheetId]?.[row]?.[column];
|
||||
// The live cell may be cleared while an earlier asynchronous dependency is still executing.
|
||||
if (formula) {
|
||||
const isSharedFollower = (formula.x ?? 0) !== 0 || (formula.y ?? 0) !== 0;
|
||||
this._currentSourceFormula = { f: isSharedFollower ? '' : formula.f, si: formula.si };
|
||||
} else {
|
||||
this._currentSourceFormula = cell?.f || cell?.si ? { f: cell.f ?? '', si: cell.si ?? undefined } : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
setFunctionRefInfoOverride(rowCount: number, columnCount: number) {
|
||||
@@ -737,6 +753,12 @@ export class FormulaRuntimeService extends Disposable implements IFormulaRuntime
|
||||
} else {
|
||||
const valueObject = this._getValueObjectOfRuntimeData(functionVariant as BaseValueObject);
|
||||
sheetData.setValue(row, column, valueObject);
|
||||
if (this._currentSourceFormula != null) {
|
||||
const unitFormulas = this._sourceFormulaData[unitId] ??= {};
|
||||
const sheetFormulas = unitFormulas[sheetId] ??= {};
|
||||
const rowFormulas = sheetFormulas[row] ??= {};
|
||||
rowFormulas[column] = { ...this._currentSourceFormula };
|
||||
}
|
||||
|
||||
// If it is the result of the IMAGE formula, the image info needs to be saved to runtimeImageFormulaData
|
||||
if ((functionVariant as BaseValueObject).isString() && (functionVariant as StringValueObject).isImage()) {
|
||||
@@ -854,6 +876,7 @@ export class FormulaRuntimeService extends Disposable implements IFormulaRuntime
|
||||
getAllRuntimeData(): IAllRuntimeData {
|
||||
return {
|
||||
unitData: this.getUnitData(),
|
||||
sourceFormulaData: this._sourceFormulaData,
|
||||
arrayFormulaRange: this.getUnitArrayFormula(),
|
||||
arrayFormulaEmbedded: this.getUnitArrayFormulaEmbeddedMap(),
|
||||
unitOtherData: this.getRuntimeOtherData(),
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
ColumnLayoutType,
|
||||
ColumnResponsiveType,
|
||||
createDocumentModelWithStyle,
|
||||
CustomRangeType,
|
||||
DataStreamTreeTokenType,
|
||||
DocumentDataModel,
|
||||
DocumentFlavor,
|
||||
@@ -43,11 +44,35 @@ import {
|
||||
} from '@univerjs/core';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { DocumentSkeleton } from '../components/docs/layout/doc-skeleton';
|
||||
import { FontCache } from '../components/docs/layout/shaping-engine/font-cache';
|
||||
import { DocumentViewModel } from '../components/docs/view-model/document-view-model';
|
||||
import { DocumentLayoutSession } from '../worker-layout';
|
||||
|
||||
type LayoutPublication = ReturnType<DocumentLayoutSession['step']>['publication'];
|
||||
|
||||
function mockWorkerCanvas(): void {
|
||||
vi.stubGlobal('document', undefined);
|
||||
vi.stubGlobal('OffscreenCanvas', class {
|
||||
getContext() {
|
||||
return {
|
||||
font: '',
|
||||
textBaseline: 'alphabetic',
|
||||
measureText(this: { font: string }, content: string) {
|
||||
const size = this.font.match(/([\d.]+)(px|pt)/);
|
||||
const points = size ? Number(size[1]) * (size[2] === 'px' ? 0.75 : 1) : 11;
|
||||
return {
|
||||
width: content.length * 7 * points / 11,
|
||||
fontBoundingBoxAscent: 9 * points / 11,
|
||||
fontBoundingBoxDescent: 3 * points / 11,
|
||||
actualBoundingBoxAscent: 8 * points / 11,
|
||||
actualBoundingBoxDescent: 2 * points / 11,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeSkeleton(value: unknown): unknown {
|
||||
if (value instanceof Map) {
|
||||
return [...value].map(([key, item]) => [key, normalizeSkeleton(item)]);
|
||||
@@ -125,6 +150,177 @@ function collectNestedElementIds(skeleton: ReturnType<DocumentSkeleton['getSkele
|
||||
describe('worker document layout session', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
Reflect.set(FontCache, '_context', null);
|
||||
FontCache.invalidateMetrics(() => true);
|
||||
});
|
||||
|
||||
it.each([0, 10_000])('publishes per-page field geometry and the converged total through the Worker protocol (budget=%s)', (budgetMs) => {
|
||||
mockWorkerCanvas();
|
||||
const univer = new Univer();
|
||||
const locale = univer.__getInjector().get(LocaleService);
|
||||
locale.setLocale(LocaleType.EN_US);
|
||||
const model = new DocumentDataModel({
|
||||
id: 'worker-fields',
|
||||
body: { dataStream: 'A\rB\rC\r\n', paragraphs: [
|
||||
{ paragraphId: 'a', startIndex: 1 },
|
||||
{ paragraphId: 'b', startIndex: 3, paragraphStyle: { pageBreakBefore: BooleanNumber.TRUE } },
|
||||
{ paragraphId: 'c', startIndex: 5, paragraphStyle: { pageBreakBefore: BooleanNumber.TRUE } },
|
||||
] },
|
||||
headers: { header: { headerId: 'header', body: {
|
||||
dataStream: '\u001F9\u001E / \u001F999\u001E\r\n',
|
||||
paragraphs: [{ paragraphId: 'hp', startIndex: 11 }],
|
||||
customRanges: [
|
||||
{ rangeId: 'page', rangeType: CustomRangeType.FIELD, startIndex: 0, endIndex: 2, wholeEntity: false, properties: { fieldType: 'PAGE' } },
|
||||
{ rangeId: 'count', rangeType: CustomRangeType.FIELD, startIndex: 6, endIndex: 10, wholeEntity: false, properties: { fieldType: 'NUMPAGES' } },
|
||||
],
|
||||
} } },
|
||||
documentStyle: { documentFlavor: DocumentFlavor.TRADITIONAL, pageNumberStart: 99, defaultHeaderId: 'header', pageSize: { width: 300, height: 400 }, marginTop: 50, marginBottom: 50 },
|
||||
});
|
||||
const target = DocumentSkeleton.create(new DocumentViewModel(new DocumentDataModel(structuredClone(model.getSnapshot()))), locale);
|
||||
const expected = DocumentSkeleton.create(new DocumentViewModel(new DocumentDataModel(structuredClone(model.getSnapshot()))), locale);
|
||||
const session = new DocumentLayoutSession(model, locale);
|
||||
try {
|
||||
expected.calculate();
|
||||
target.beginExternalLayout({ reason: 'initial' });
|
||||
const generation = session.start({ reason: 'initial' });
|
||||
let complete = false;
|
||||
let pendingPublications = 0;
|
||||
for (let step = 0; step < 100 && !complete; step++) {
|
||||
const result = session.step(generation, step < 2 ? 0 : budgetMs);
|
||||
if (result.publication) {
|
||||
target.applyLayoutPublication(structuredClone(result.publication), result.progress);
|
||||
if (result.progress.processedBlockCount < result.progress.totalBlockCount) {
|
||||
pendingPublications++;
|
||||
const pendingData = target.getSkeletonData()!;
|
||||
for (const page of pendingData.pages) {
|
||||
const header = pendingData.skeHeaders.get('header')!.get(page.headerLayoutKey!)!;
|
||||
const contents = header.sections[0].columns[0].lines.flatMap((line) => line.divides.flatMap((divide) => divide.glyphGroup.map((glyph) => glyph.content))).join('');
|
||||
expect(contents.replace(/[\u001E\u001F\r]/g, '')).toBe(`${page.pageNumber} / 999`);
|
||||
}
|
||||
}
|
||||
}
|
||||
complete = result.progress.complete;
|
||||
}
|
||||
expect(complete).toBe(true);
|
||||
expect(pendingPublications).toBeGreaterThan(0);
|
||||
expect(target.getCompletedPageCount()).toBe(3);
|
||||
const data = target.getSkeletonData()!;
|
||||
expect(normalizeSkeleton(data.pages)).toEqual(normalizeSkeleton(expected.getSkeletonData()!.pages));
|
||||
for (const [index, page] of data.pages.entries()) {
|
||||
const header = data.skeHeaders.get('header')!.get(page.headerLayoutKey!)!;
|
||||
const contents = header.sections[0].columns[0].lines.flatMap((line) => line.divides.flatMap((divide) => divide.glyphGroup.map((glyph) => glyph.content))).join('');
|
||||
expect(contents.replace(/[\u001E\u001F\r]/g, '')).toBe(`${99 + index} / 3`);
|
||||
expect(target.findNodeByCharIndex(1, 'header', index)?.content).toBe(String(99 + index));
|
||||
}
|
||||
const edited = structuredClone(model.getSnapshot());
|
||||
edited.body!.paragraphs![1].paragraphStyle!.pageBreakBefore = BooleanNumber.FALSE;
|
||||
const editedModel = new DocumentDataModel(edited);
|
||||
const targetModel = new DocumentDataModel(structuredClone(edited));
|
||||
try {
|
||||
session.resetDataModel(editedModel);
|
||||
target.getViewModel().reset(targetModel);
|
||||
target.beginExternalLayout({ reason: 'edit' });
|
||||
const editGeneration = session.start({ reason: 'edit' });
|
||||
let editComplete = false;
|
||||
for (let step = 0; step < 100 && !editComplete; step++) {
|
||||
const result = session.step(editGeneration, 0);
|
||||
if (result.publication) {
|
||||
target.applyLayoutPublication(structuredClone(result.publication), result.progress);
|
||||
}
|
||||
editComplete = result.progress.complete;
|
||||
expect(target.getCompletedPageCount()).toBe(editComplete ? 2 : 3);
|
||||
}
|
||||
expect(editComplete).toBe(true);
|
||||
const page = target.getSkeletonData()!.pages[0];
|
||||
const header = target.getSkeletonData()!.skeHeaders.get('header')!.get(page.headerLayoutKey!)!;
|
||||
const text = header.sections[0].columns[0].lines.flatMap((line) => line.divides.flatMap((divide) => divide.glyphGroup.map((glyph) => glyph.content))).join('');
|
||||
expect(text.replace(/[\u001E\u001F\r]/g, '')).toBe('99 / 2');
|
||||
} finally {
|
||||
editedModel.dispose();
|
||||
targetModel.dispose();
|
||||
}
|
||||
} finally {
|
||||
session.dispose();
|
||||
target.dispose();
|
||||
expected.dispose();
|
||||
model.dispose();
|
||||
univer.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it('replaces protected header geometry when inserting the first page field', () => {
|
||||
mockWorkerCanvas();
|
||||
const univer = new Univer();
|
||||
const locale = univer.__getInjector().get(LocaleService);
|
||||
locale.setLocale(LocaleType.EN_US);
|
||||
const model = new DocumentDataModel({
|
||||
id: 'worker-header-insertion',
|
||||
body: { dataStream: 'A\rB\r\n', paragraphs: [
|
||||
{ paragraphId: 'a', startIndex: 1 },
|
||||
{ paragraphId: 'b', startIndex: 3, paragraphStyle: { pageBreakBefore: BooleanNumber.TRUE } },
|
||||
] },
|
||||
headers: { header: { headerId: 'header', body: {
|
||||
dataStream: 'Page \r\n',
|
||||
paragraphs: [{ paragraphId: 'hp', startIndex: 5 }],
|
||||
} } },
|
||||
documentStyle: { documentFlavor: DocumentFlavor.TRADITIONAL, pageNumberStart: 9, defaultHeaderId: 'header', pageSize: { width: 300, height: 400 }, marginTop: 50, marginBottom: 50 },
|
||||
});
|
||||
const target = DocumentSkeleton.create(new DocumentViewModel(model), locale);
|
||||
const foreground = DocumentSkeleton.create(new DocumentViewModel(model), locale);
|
||||
const session = new DocumentLayoutSession(model, locale);
|
||||
const snapshot = structuredClone(model.getSnapshot());
|
||||
snapshot.headers!.header.body = {
|
||||
dataStream: 'Page \u001F9\u001E\r\n',
|
||||
paragraphs: [{ paragraphId: 'hp', startIndex: 8 }],
|
||||
customRanges: [{ rangeId: 'page', rangeType: CustomRangeType.FIELD, startIndex: 5, endIndex: 7, wholeEntity: false, properties: { fieldType: 'PAGE' } }],
|
||||
};
|
||||
const edited = new DocumentDataModel(snapshot);
|
||||
try {
|
||||
target.calculate();
|
||||
foreground.calculate();
|
||||
foreground.getViewModel().reset(edited);
|
||||
const foregroundGeneration = foreground.startIncrementalLayout({ reason: 'edit', anchor: 0, reuseUnaffectedTail: false });
|
||||
let foregroundComplete = false;
|
||||
for (let step = 0; step < 100 && !foregroundComplete; step++) {
|
||||
foregroundComplete = foreground.stepIncrementalLayout(foregroundGeneration, 0).complete;
|
||||
}
|
||||
expect(foregroundComplete).toBe(true);
|
||||
expect(foreground.getSkeletonData()!.pages[0].headerLayoutKey).toBeDefined();
|
||||
expect(foreground.findNodeByCharIndex(6, 'header', 0)?.content).toBe('9');
|
||||
const initial = session.start({ reason: 'initial' });
|
||||
let initialComplete = false;
|
||||
for (let step = 0; step < 100 && !initialComplete; step++) {
|
||||
initialComplete = session.step(initial, 0).progress.complete;
|
||||
}
|
||||
expect(initialComplete).toBe(true);
|
||||
session.resetDataModel(edited);
|
||||
target.getViewModel().reset(edited);
|
||||
target.beginExternalLayout({ reason: 'edit', protectedRange: { mode: 'paginated', startPageIndex: 0, endPageIndex: 0 } });
|
||||
const generation = session.start({ reason: 'edit', anchor: 0 });
|
||||
let complete = false;
|
||||
for (let step = 0; step < 100 && !complete; step++) {
|
||||
const result = session.step(generation, 0);
|
||||
if (result.publication) {
|
||||
target.applyLayoutPublication(structuredClone(result.publication), result.progress);
|
||||
}
|
||||
complete = result.progress.complete;
|
||||
}
|
||||
expect(complete).toBe(true);
|
||||
const data = target.getSkeletonData()!;
|
||||
for (const [index, page] of data.pages.entries()) {
|
||||
expect(page.headerLayoutKey).toBeDefined();
|
||||
const header = data.skeHeaders.get('header')!.get(page.headerLayoutKey!)!;
|
||||
const text = header.sections[0].columns[0].lines.flatMap((line) => line.divides.flatMap((divide) => divide.glyphGroup.map((glyph) => glyph.content))).join('');
|
||||
expect(text.replace(/[\u001E\u001F\r]/g, '')).toBe(`Page ${9 + index}`);
|
||||
}
|
||||
} finally {
|
||||
session.dispose();
|
||||
target.dispose();
|
||||
foreground.dispose();
|
||||
model.dispose();
|
||||
edited.dispose();
|
||||
univer.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it('paginates and resolves a 1000-page TOC target incrementally', {
|
||||
|
||||
@@ -59,8 +59,8 @@ export interface IDocumentSkeletonParagraphBorders {
|
||||
}
|
||||
|
||||
export interface ISkeletonResourceReference {
|
||||
skeHeaders: Map<string, Map<number, IDocumentSkeletonHeaderFooter>>; // id:{ width: IDocumentSkeletonHeaderFooter }
|
||||
skeFooters: Map<string, Map<number, IDocumentSkeletonHeaderFooter>>;
|
||||
skeHeaders: Map<string, Map<number | string, IDocumentSkeletonHeaderFooter>>; // id:{ width: IDocumentSkeletonHeaderFooter }
|
||||
skeFooters: Map<string, Map<number | string, IDocumentSkeletonHeaderFooter>>;
|
||||
/* Global cache, does not participate in rendering, only helps skeleton generation */
|
||||
skeListLevel?: Map<string, IParagraphList[][]>; // ordered list cache, id: { level: max(width) bullet }
|
||||
drawingAnchor?: Map<string, Map<number, IDocumentSkeletonDrawingAnchor>>; // Anchor point to assist floating element positioning
|
||||
@@ -103,6 +103,10 @@ export interface IDocumentSkeletonPage {
|
||||
sections: IDocumentSkeletonSection[];
|
||||
headerId: string;
|
||||
footerId: string;
|
||||
/** Per-page field geometry; plain stories keep the legacy width cache key. */
|
||||
fieldsResolved?: boolean;
|
||||
headerLayoutKey?: string;
|
||||
footerLayoutKey?: string;
|
||||
// page
|
||||
pageWidth: number; // default Infinity, The current page width follows the snapshot configuration, determined jointly by documentStyle and sectionBreak, representing the static limit for each page, unrelated to the actual content's text width.
|
||||
pageHeight: number; // default Infinity, The current page's height follows the snapshot configuration, jointly determined by documentStyle and sectionBreak, representing the static limit for each page, unrelated to the total height of the actual content's text.
|
||||
|
||||
@@ -163,8 +163,8 @@ export interface IParagraphConfig {
|
||||
paragraphStyle?: IDocumentLayoutParagraphStyle;
|
||||
borderTopSpace?: number;
|
||||
borderBottomSpace?: number;
|
||||
skeHeaders: Map<string, Map<number, IDocumentSkeletonHeaderFooter>>;
|
||||
skeFooters: Map<string, Map<number, IDocumentSkeletonHeaderFooter>>;
|
||||
skeHeaders: Map<string, Map<number | string, IDocumentSkeletonHeaderFooter>>;
|
||||
skeFooters: Map<string, Map<number | string, IDocumentSkeletonHeaderFooter>>;
|
||||
pDrawingAnchor?: Map<number, IDocumentSkeletonDrawingAnchor>;
|
||||
// sectionBreakConfig: ISectionBreakConfig;
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ import { Path, Rect } from '../../../shape';
|
||||
import { Viewport } from '../../../viewport';
|
||||
import { DocBackground } from '../doc-background';
|
||||
import { DOCS_EXTENSION_TYPE } from '../doc-extension';
|
||||
import { Documents, drawSectionColumnSeparators, resolveHeaderFooterFieldGlyph } from '../document';
|
||||
import { Documents, drawSectionColumnSeparators } from '../document';
|
||||
import { createParagraphLayoutTestBed } from '../layout/block/paragraph/__tests__/create-paragraph-layout-test-bed';
|
||||
import { DocumentSkeleton } from '../layout/doc-skeleton';
|
||||
import { setDocsTableRenderViewportProvider } from '../table-render-viewport';
|
||||
@@ -338,15 +338,6 @@ function attachColumnGroup(page: any) {
|
||||
}
|
||||
|
||||
describe('documents render', () => {
|
||||
it('resolves PAGE and NUMPAGES fields without mutating the model glyph', () => {
|
||||
const glyph = { st: 1, ed: 1, content: '1' } as any;
|
||||
const pageRange = { startIndex: 0, endIndex: 2, properties: { fieldType: 'PAGE' } } as any;
|
||||
const pageCountRange = { startIndex: 0, endIndex: 2, properties: { fieldType: 'NUMPAGES' } } as any;
|
||||
|
||||
expect(resolveHeaderFooterFieldGlyph(glyph, 1, 1, [pageRange], 15, 16).content).toBe('15');
|
||||
expect(resolveHeaderFooterFieldGlyph(glyph, 1, 1, [pageCountRange], 15, 16).content).toBe('16');
|
||||
expect(glyph.content).toBe('1');
|
||||
});
|
||||
let restoreEnv: () => void;
|
||||
let container: HTMLDivElement;
|
||||
let engine: Engine;
|
||||
|
||||
@@ -50,7 +50,6 @@ import {
|
||||
CellValueType,
|
||||
ColumnSeparatorType,
|
||||
DashStyleType,
|
||||
DataStreamTreeTokenType,
|
||||
DocumentFlavor,
|
||||
HorizontalAlign,
|
||||
TableTextWrapType,
|
||||
@@ -71,6 +70,7 @@ 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 { resolveHeaderFooterFieldGlyph } from './layout/header-footer-field';
|
||||
import { getTextHorizontalScale } from './layout/model/glyph';
|
||||
import { FontCache } from './layout/shaping-engine/font-cache';
|
||||
import { getColorStyleForCanvas } from './layout/style/color';
|
||||
@@ -134,37 +134,6 @@ function getVisibleContinuousLineRange(
|
||||
return { start, end: low };
|
||||
}
|
||||
|
||||
export function resolveHeaderFooterFieldGlyph(
|
||||
glyph: IDocumentSkeletonGlyph,
|
||||
startIndex: number,
|
||||
endIndex: number,
|
||||
customRanges: ICustomRange[],
|
||||
pageNumber: number,
|
||||
pageCount: number
|
||||
): IDocumentSkeletonGlyph {
|
||||
if (!glyph.content || glyph.raw === DataStreamTreeTokenType.CUSTOM_RANGE_START ||
|
||||
glyph.raw === DataStreamTreeTokenType.CUSTOM_RANGE_END) {
|
||||
return glyph;
|
||||
}
|
||||
const fieldRange = customRanges.find((customRange) =>
|
||||
customRange.startIndex <= startIndex &&
|
||||
customRange.endIndex >= endIndex &&
|
||||
typeof customRange.properties?.fieldType === 'string'
|
||||
);
|
||||
const fieldType = fieldRange?.properties?.fieldType?.toUpperCase();
|
||||
if (!fieldRange || (fieldType !== 'PAGE' && fieldType !== 'NUMPAGES')) {
|
||||
return glyph;
|
||||
}
|
||||
// FIELD endpoints are invisible sentinels. Paint the value once even when
|
||||
// the cached digits were split into several differently formatted runs.
|
||||
let content = '';
|
||||
if (startIndex === fieldRange.startIndex + 1) {
|
||||
content = String(fieldType === 'PAGE' ? pageNumber : pageCount);
|
||||
}
|
||||
|
||||
return content === glyph.content ? glyph : { ...glyph, content };
|
||||
}
|
||||
|
||||
export interface IPageRenderConfig {
|
||||
page: IDocumentSkeletonPage;
|
||||
pageLeft: number;
|
||||
@@ -183,8 +152,8 @@ function resolveHeaderFooterFieldGlyphGroup(
|
||||
divide: IDocumentSkeletonDivide,
|
||||
context: IHeaderFooterFieldContext
|
||||
): IDocumentSkeletonGlyph[] {
|
||||
// Header/footer skeletons are shared between body pages; keep cached advances immutable.
|
||||
// ponytail: Preserve cached line breaks; overflowing fields need per-page header/footer layout.
|
||||
// Legacy skeletons and note stories resolve fields at draw time. New header/footer
|
||||
// layouts already resolve their fields before wrapping and bypass this fallback.
|
||||
const { glyphGroup } = divide;
|
||||
let index = divide.st;
|
||||
const resolved = glyphGroup.map((glyph) => {
|
||||
@@ -572,7 +541,7 @@ export class Documents extends DocComponent {
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
const headerSkeletonPage = skeHeaders.get(headerId)?.get(pageWidth);
|
||||
const headerSkeletonPage = skeHeaders.get(headerId)?.get(page.headerLayoutKey ?? pageWidth);
|
||||
|
||||
const headerAlignOffsetNoAngle = Vector2.create(
|
||||
horizontalOffsetNoAngle,
|
||||
@@ -663,7 +632,7 @@ export class Documents extends DocComponent {
|
||||
);
|
||||
}
|
||||
|
||||
const footerSkeletonPage = skeFooters.get(footerId)?.get(pageWidth);
|
||||
const footerSkeletonPage = skeFooters.get(footerId)?.get(page.footerLayoutKey ?? pageWidth);
|
||||
|
||||
if (footerSkeletonPage) {
|
||||
const footerAlignOffsetNoAngle = Vector2.create(
|
||||
@@ -1899,7 +1868,7 @@ export class Documents extends DocComponent {
|
||||
.getViewModel()
|
||||
.getSelfOrHeaderFooterViewModel(page.segmentId)
|
||||
: undefined;
|
||||
const customRanges = customRangesOverride ?? viewModel?.getBody()?.customRanges ?? [];
|
||||
const customRanges = page.fieldsResolved ? [] : customRangesOverride ?? viewModel?.getBody()?.customRanges ?? [];
|
||||
const fieldContext = { customRanges, pageNumber: parentPage.pageNumber, pageCount, viewModel };
|
||||
|
||||
ctx.save();
|
||||
|
||||
@@ -1655,6 +1655,99 @@ describe('doc skeleton', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it.each([{ firstPageNumber: 9, table: false }, { firstPageNumber: 99, table: false }, { firstPageNumber: 99, table: true }])('lays out repeated page fields at $firstPageNumber (table=$table) with per-page geometry and a final page count', ({ firstPageNumber, table }) => {
|
||||
mockCanvasTextMetrics(11, 8);
|
||||
const univer = new Univer();
|
||||
const locale = univer.__getInjector().get(LocaleService);
|
||||
locale.setLocale(LocaleType.EN_US);
|
||||
const T = DataStreamTreeTokenType;
|
||||
const text = 'x \u001F9\u001E z\r\n';
|
||||
const story = table ? `${T.TABLE_START}${T.TABLE_ROW_START}${T.TABLE_CELL_START}${text}${T.TABLE_CELL_END}${T.TABLE_ROW_END}${T.TABLE_END}\r\n` : text;
|
||||
const offset = table ? 3 : 0;
|
||||
const contentWidth = (String(firstPageNumber).length + 4) * 8;
|
||||
const snapshot: IDocumentData = {
|
||||
id: 'page-fields-layout',
|
||||
tableSource: table
|
||||
? { table: {
|
||||
tableId: 'table',
|
||||
align: TableAlignmentType.START,
|
||||
indent: { v: 0 },
|
||||
textWrap: TableTextWrapType.NONE,
|
||||
position: { positionH: { relativeFrom: ObjectRelativeFromH.PAGE }, positionV: { relativeFrom: ObjectRelativeFromV.PAGE } },
|
||||
dist: { distT: 0, distB: 0, distL: 0, distR: 0 },
|
||||
size: { type: TableSizeType.SPECIFIED, width: { v: contentWidth } },
|
||||
cellMargin: { top: { v: 0 }, bottom: { v: 0 }, start: { v: 0 }, end: { v: 0 } },
|
||||
tableRows: [{ tableCells: [{}], trHeight: { val: { v: 0 }, hRule: TableRowHeightRule.AUTO } }],
|
||||
tableColumns: [{ size: { type: TableSizeType.SPECIFIED, width: { v: contentWidth } } }],
|
||||
} }
|
||||
: undefined,
|
||||
body: { dataStream: 'A\rB\r\n', paragraphs: [
|
||||
{ paragraphId: 'p1', startIndex: 1 },
|
||||
{ paragraphId: 'p2', startIndex: 3, paragraphStyle: { pageBreakBefore: BooleanNumber.TRUE } },
|
||||
] },
|
||||
headers: { header: { headerId: 'header', body: {
|
||||
dataStream: story,
|
||||
paragraphs: [...story.matchAll(/\r/g)].map((match, index) => ({ paragraphId: `hp-${index}`, startIndex: match.index })),
|
||||
sectionBreaks: [...story.matchAll(/\n/g)].map((match, index) => ({ sectionId: `hs-${index}`, startIndex: match.index })),
|
||||
tables: table ? [{ tableId: 'table', startIndex: 0, endIndex: story.length - 2 }] : [],
|
||||
customRanges: [{ rangeId: 'page', rangeType: CustomRangeType.FIELD, startIndex: offset + 2, endIndex: offset + 4, wholeEntity: false, properties: { fieldType: 'PAGE' } }],
|
||||
} } },
|
||||
footers: { footer: { footerId: 'footer', body: {
|
||||
dataStream: '\u001F999\u001E\r\n',
|
||||
paragraphs: [{ paragraphId: 'fp', startIndex: 5 }],
|
||||
customRanges: [{ rangeId: 'count', rangeType: CustomRangeType.FIELD, startIndex: 0, endIndex: 4, wholeEntity: false, properties: { fieldType: 'NUMPAGES' } }],
|
||||
} } },
|
||||
documentStyle: {
|
||||
documentFlavor: DocumentFlavor.TRADITIONAL,
|
||||
pageNumberStart: firstPageNumber,
|
||||
pageSize: { width: contentWidth + 32, height: 400 },
|
||||
marginLeft: 16,
|
||||
marginRight: 16,
|
||||
marginTop: 60,
|
||||
marginBottom: 60,
|
||||
marginHeader: 5,
|
||||
marginFooter: 5,
|
||||
textStyle: { ff: 'Arial', fs: 11 },
|
||||
defaultHeaderId: 'header',
|
||||
defaultFooterId: 'footer',
|
||||
},
|
||||
};
|
||||
const main = DocumentSkeleton.create(new DocumentViewModel(new DocumentDataModel(snapshot)), locale);
|
||||
const incremental = DocumentSkeleton.create(new DocumentViewModel(new DocumentDataModel(snapshot)), locale);
|
||||
try {
|
||||
main.calculate();
|
||||
const generation = incremental.startIncrementalLayout();
|
||||
let steps = 0;
|
||||
while (!incremental.stepIncrementalLayout(generation, 8, 1).complete) {
|
||||
expect(++steps).toBeLessThan(100);
|
||||
}
|
||||
expect(normalizeSkeleton(incremental.getSkeletonData())).toEqual(normalizeSkeleton(main.getSkeletonData()));
|
||||
const data = main.getSkeletonData()!;
|
||||
expect(data.pages).toHaveLength(2);
|
||||
const pages = data.pages.map((page) => data.skeHeaders.get('header')!.get(page.headerLayoutKey!)!);
|
||||
expect(pages[0]).not.toBe(pages[1]);
|
||||
const textPages = table ? pages.map((page) => [...page.skeTables.values()][0].rows[0].cells[0]) : pages;
|
||||
const lines = textPages.map((page) => page.sections.flatMap((section) => section.columns.flatMap((column) => column.lines)));
|
||||
expect(lines[0]).toHaveLength(1);
|
||||
expect(lines[1]).toHaveLength(2);
|
||||
const glyphs = textPages.map((page) => page.sections.flatMap((section) => section.columns.flatMap((column) => column.lines.flatMap((line) => line.divides.flatMap((divide) => divide.glyphGroup)))));
|
||||
expect(glyphs[0].find((glyph) => glyph.content === String(firstPageNumber))).toBeDefined();
|
||||
const next = glyphs[1].find((glyph) => glyph.content === String(firstPageNumber + 1))!;
|
||||
expect(next).toBeDefined();
|
||||
expect(next.width).toBeGreaterThan(glyphs[0].find((glyph) => glyph.content === String(firstPageNumber))!.width);
|
||||
expect(main.findNodeByCharIndex(offset + 3, 'header', 1)?.content).toBe(String(firstPageNumber + 1));
|
||||
for (const page of data.pages) {
|
||||
const footer = data.skeFooters.get('footer')!.get(page.footerLayoutKey!)!;
|
||||
const contents = footer.sections[0].columns[0].lines.flatMap((line) => line.divides.flatMap((divide) => divide.glyphGroup.map((glyph) => glyph.content))).join('');
|
||||
expect(contents.replace(/[\u001E\u001F\r]/g, '')).toBe('2');
|
||||
}
|
||||
} finally {
|
||||
main.dispose();
|
||||
incremental.dispose();
|
||||
univer.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it('lays out a TOC page number through nested field markers at the right tab stop', () => {
|
||||
const univer = new Univer();
|
||||
const snapshot: IDocumentData = {
|
||||
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* 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 { ICustomRange } from '@univerjs/core';
|
||||
import type { IDocumentSkeletonGlyph } from '../../../../basics/i-document-skeleton-cached';
|
||||
import { BooleanNumber, CustomRangeType } from '@univerjs/core';
|
||||
import { expect, it } from 'vitest';
|
||||
import { resolveHeaderFooterFieldGlyph } from '../header-footer-field';
|
||||
|
||||
it('resolves numeric fields without changing cached glyphs and preserves locked or pending values', () => {
|
||||
const glyph = { content: '1' } as IDocumentSkeletonGlyph;
|
||||
const pageRange: ICustomRange = { rangeId: 'page', rangeType: CustomRangeType.FIELD, startIndex: 0, endIndex: 2, properties: { fieldType: 'PAGE' } };
|
||||
const pageCountRange = { ...pageRange, properties: { fieldType: 'NUMPAGES' } };
|
||||
expect(resolveHeaderFooterFieldGlyph(glyph, 1, 1, [pageRange], 15, 16).content).toBe('15');
|
||||
expect(resolveHeaderFooterFieldGlyph(glyph, 1, 1, [pageCountRange], 15, 16).content).toBe('16');
|
||||
expect(resolveHeaderFooterFieldGlyph(glyph, 1, 1, [pageCountRange], 15, undefined)).toBe(glyph);
|
||||
expect(resolveHeaderFooterFieldGlyph(glyph, 1, 1, [{ ...pageRange, properties: { fieldType: 'PAGE', locked: BooleanNumber.TRUE } }], 15, 16)).toBe(glyph);
|
||||
expect(glyph.content).toBe('1');
|
||||
});
|
||||
@@ -1746,8 +1746,8 @@ function _lineOperator(
|
||||
const lineTop = preLineHeight + preTop;
|
||||
|
||||
const { pageWidth, headerId, footerId } = lastPage;
|
||||
const headerPage = skeHeaders?.get(headerId)?.get(pageWidth);
|
||||
const footerPage = skeFooters?.get(footerId)?.get(pageWidth);
|
||||
const headerPage = skeHeaders?.get(headerId)?.get(lastPage.headerLayoutKey ?? pageWidth);
|
||||
const footerPage = skeFooters?.get(footerId)?.get(lastPage.footerLayoutKey ?? pageWidth);
|
||||
|
||||
let needOpenNewPageByTableLayout = false;
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
} from '../../../../../basics/tools';
|
||||
import { getDocsCustomBlockRenderViewport } from '../../../custom-block-render-viewport';
|
||||
import { getDocumentCompatibilityPolicy, isTraditionalDocumentCompatibility } from '../../../document-compatibility';
|
||||
import { resolveHeaderFooterFieldGlyph } from '../../header-footer-field';
|
||||
import { Lang } from '../../hyphenation/lang';
|
||||
import { BreakPointType } from '../../line-breaker/break';
|
||||
import { LineBreakerHyphenEnhancer } from '../../line-breaker/enhancers/hyphen-enhancer';
|
||||
@@ -60,10 +61,12 @@ import {
|
||||
createSkeletonLetterGlyph,
|
||||
createSkeletonTabGlyph,
|
||||
createSkeletonWholeEntityGlyph,
|
||||
getTextHorizontalScale,
|
||||
glyphShrinkLeft,
|
||||
glyphShrinkRight,
|
||||
} from '../../model/glyph';
|
||||
import { getBoundingBox } from '../../model/line';
|
||||
import { FontCache } from '../../shaping-engine/font-cache';
|
||||
import {
|
||||
getCharSpaceApply,
|
||||
getCustomRangeGlyphMetrics,
|
||||
@@ -711,6 +714,33 @@ export function shaping(
|
||||
punctuationSpaceAdjustment(shapedGlyphs, fixedPunctuationPairs);
|
||||
}
|
||||
|
||||
if (ctx.headerFooterFieldContext) {
|
||||
let index = paragraphNode.contentStartIndex + last;
|
||||
for (let glyphIndex = 0; glyphIndex < shapedGlyphs.length; glyphIndex++) {
|
||||
const glyph = shapedGlyphs[glyphIndex];
|
||||
const resolved = resolveHeaderFooterFieldGlyph(
|
||||
glyph,
|
||||
index,
|
||||
index + glyph.count - 1,
|
||||
viewModel.getBody()?.customRanges ?? [],
|
||||
ctx.headerFooterFieldContext.pageNumber,
|
||||
ctx.headerFooterFieldContext.pageCount
|
||||
);
|
||||
index += glyph.count;
|
||||
if (resolved === glyph) {
|
||||
continue;
|
||||
}
|
||||
const font = glyph.fontStyle?.fontString;
|
||||
const widthDelta = resolved.content && font
|
||||
? (FontCache.getMeasureText(resolved.content, font).width - FontCache.getMeasureText(glyph.content, font).width) * getTextHorizontalScale(glyph.ts?.sa)
|
||||
: -glyph.width;
|
||||
shapedGlyphs[glyphIndex] = {
|
||||
...resolved,
|
||||
width: Math.max(0, glyph.width + widthDelta),
|
||||
bBox: { ...glyph.bBox, width: Math.max(0, glyph.bBox.width + widthDelta) },
|
||||
};
|
||||
}
|
||||
}
|
||||
appendShapedText(shapedTextList, shapedGlyphs, bk.type, paragraphStyle.fixedTabStops === BooleanNumber.TRUE);
|
||||
|
||||
last = bk.position;
|
||||
|
||||
@@ -65,6 +65,7 @@ import type { IDocumentPaginationMetrics, ILayoutContext } from './tools';
|
||||
import {
|
||||
BaselineOffset,
|
||||
BooleanNumber,
|
||||
CustomRangeType,
|
||||
DataStreamTreeNodeType,
|
||||
DataStreamTreeTokenType,
|
||||
DocumentFlavor,
|
||||
@@ -362,6 +363,7 @@ interface IIncrementalLayoutState {
|
||||
lastPublishedPageCount: number;
|
||||
lastPublishedBlockCount: number;
|
||||
publicationRevision: number;
|
||||
paginationRevision: number;
|
||||
reuseUnaffectedTail: boolean;
|
||||
allowMetadataOnlyStructuralTailReuse: boolean;
|
||||
reusedTail: boolean;
|
||||
@@ -1028,6 +1030,8 @@ function serializePaginatedContinuationCheckpoint(page: IDocumentSkeletonPagePat
|
||||
sectionId: page.sectionId,
|
||||
headerId: page.headerId,
|
||||
footerId: page.footerId,
|
||||
headerLayoutKey: page.headerLayoutKey,
|
||||
footerLayoutKey: page.footerLayoutKey,
|
||||
pageWidth: page.pageWidth,
|
||||
pageHeight: page.pageHeight,
|
||||
pageOrient: page.pageOrient,
|
||||
@@ -1156,8 +1160,8 @@ function getSegmentPageFromRelativePath(
|
||||
|
||||
const { headerId, footerId, pageWidth } = rootPage;
|
||||
const segmentPages = [
|
||||
headerId == null ? null : skeletonData.skeHeaders.get(headerId)?.get(pageWidth),
|
||||
footerId == null ? null : skeletonData.skeFooters.get(footerId)?.get(pageWidth),
|
||||
headerId == null ? null : skeletonData.skeHeaders.get(headerId)?.get(rootPage.headerLayoutKey ?? pageWidth),
|
||||
footerId == null ? null : skeletonData.skeFooters.get(footerId)?.get(rootPage.footerLayoutKey ?? pageWidth),
|
||||
];
|
||||
|
||||
for (const segmentPage of segmentPages) {
|
||||
@@ -1201,6 +1205,7 @@ export class DocumentSkeleton extends Skeleton {
|
||||
|
||||
private _paginationMetrics: Nullable<IDocumentPaginationMetrics> = null;
|
||||
|
||||
private _completedPageCount: number | undefined;
|
||||
private _lastCompleteSkeletonData: Nullable<IDocumentSkeletonCached> = null;
|
||||
|
||||
private _pendingInvalidationAnchor: Nullable<number> = null;
|
||||
@@ -1261,6 +1266,7 @@ export class DocumentSkeleton extends Skeleton {
|
||||
this._layoutPageMaterialized$.complete();
|
||||
this._skeletonData = null;
|
||||
this._lastCompleteSkeletonData = null;
|
||||
this._completedPageCount = undefined;
|
||||
this._pendingInvalidationAnchor = null;
|
||||
this._externalLayoutProgress = null;
|
||||
this._externalProtectedPages = null;
|
||||
@@ -1288,9 +1294,17 @@ export class DocumentSkeleton extends Skeleton {
|
||||
this._externalLayoutProgress = null;
|
||||
this._externalProtectedPages = null;
|
||||
this._externalProtectedContinuousLayout = null;
|
||||
const ctx = this._prepareLayoutContext();
|
||||
this._skeletonData = this._createSkeleton(ctx, bounds);
|
||||
let ctx = this._prepareLayoutContext();
|
||||
while (true) {
|
||||
this._skeletonData = this._createSkeleton(ctx, bounds);
|
||||
const retry = this._prepareFieldCountRetry(ctx);
|
||||
if (retry == null) {
|
||||
break;
|
||||
}
|
||||
ctx = retry;
|
||||
}
|
||||
this._lastCompleteSkeletonData = this._skeletonData;
|
||||
this._completedPageCount = this._skeletonData?.pages.length;
|
||||
this._pendingInvalidationAnchor = null;
|
||||
this._paginationMetrics = ctx.paginationMetrics ?? null;
|
||||
this._dirty$.next(true);
|
||||
@@ -1415,6 +1429,7 @@ export class DocumentSkeleton extends Skeleton {
|
||||
lastPublishedBlockCount: incrementalStart.processedBlockCount,
|
||||
publicationRevision: 0,
|
||||
reuseUnaffectedTail: options?.reuseUnaffectedTail !== false,
|
||||
paginationRevision: 0,
|
||||
allowMetadataOnlyStructuralTailReuse: options?.allowMetadataOnlyStructuralTailReuse === true,
|
||||
reusedTail: false,
|
||||
tailConvergencePageCount: incrementalStart.ctx.skeleton.pages.length,
|
||||
@@ -1506,7 +1521,7 @@ export class DocumentSkeleton extends Skeleton {
|
||||
blocks[anchorBlockIndex].block.children.length === 0;
|
||||
// A Modern page can contain the entire document. Paginated tail reuse
|
||||
// deep-clones that page in one atomic step, bypassing the slice budget.
|
||||
if (mode === 'paginated' && reuseInteractionPagePrefix && canResumePlainParagraph &&
|
||||
if (mode === 'paginated' && reuseInteractionPagePrefix && invalidation != null && canResumePlainParagraph &&
|
||||
!previousSkeleton.pages[anchorPageIndex]?.notes?.length) {
|
||||
const interactionPreviousAnchor = mapCurrentOffsetToPrevious(anchor, invalidation);
|
||||
const interactionPageIndex = interactionSkeleton == null
|
||||
@@ -2150,6 +2165,8 @@ export class DocumentSkeleton extends Skeleton {
|
||||
if (
|
||||
!state.cancelled &&
|
||||
canPublish &&
|
||||
// A NUMPAGES retry is provisional until its pagination converges.
|
||||
(state.ctx.fieldCountCandidates == null || state.complete) &&
|
||||
(!suppressPreAnchorPublication || shouldPublishPriority || state.complete)
|
||||
) {
|
||||
if (state.mode === 'paginated') {
|
||||
@@ -2158,6 +2175,7 @@ export class DocumentSkeleton extends Skeleton {
|
||||
const isFinalPublication = state.complete && publishedPageCount >= pageCount;
|
||||
if (isFinalPublication) {
|
||||
this._lastCompleteSkeletonData = state.ctx.skeleton;
|
||||
this._completedPageCount = state.ctx.skeleton?.pages.length;
|
||||
this._pendingInvalidationAnchor = null;
|
||||
}
|
||||
this._skeletonData = isFinalPublication
|
||||
@@ -2655,6 +2673,7 @@ export class DocumentSkeleton extends Skeleton {
|
||||
if (progress.complete) {
|
||||
setPageParent(skeletonData.pages, skeletonData);
|
||||
this._lastCompleteSkeletonData = skeletonData;
|
||||
this._completedPageCount = skeletonData?.pages.length;
|
||||
this._pendingInvalidationAnchor = null;
|
||||
this._externalProtectedContinuousLayout = null;
|
||||
}
|
||||
@@ -2799,6 +2818,7 @@ export class DocumentSkeleton extends Skeleton {
|
||||
}
|
||||
setPageParent(skeletonData.pages, skeletonData);
|
||||
this._lastCompleteSkeletonData = skeletonData;
|
||||
this._completedPageCount = skeletonData?.pages.length;
|
||||
this._pendingInvalidationAnchor = null;
|
||||
this._externalProtectedPages = null;
|
||||
this._externalProtectedContinuousLayout = null;
|
||||
@@ -3191,14 +3211,14 @@ export class DocumentSkeleton extends Skeleton {
|
||||
const { headerId, footerId, pageWidth } = skePage;
|
||||
|
||||
if (pageType === DocumentSkeletonPageType.HEADER) {
|
||||
const skeHeader = skeHeaders.get(headerId)?.get(pageWidth);
|
||||
const skeHeader = skeHeaders.get(headerId)?.get(skePage.headerLayoutKey ?? pageWidth);
|
||||
if (skeHeader == null) {
|
||||
return;
|
||||
} else {
|
||||
skePage = skeHeader;
|
||||
}
|
||||
} else if (pageType === DocumentSkeletonPageType.FOOTER) {
|
||||
const skeFooter = skeFooters.get(footerId)?.get(pageWidth);
|
||||
const skeFooter = skeFooters.get(footerId)?.get(skePage.footerLayoutKey ?? pageWidth);
|
||||
if (skeFooter == null) {
|
||||
return;
|
||||
} else {
|
||||
@@ -3395,7 +3415,7 @@ export class DocumentSkeleton extends Skeleton {
|
||||
let exactMatch: Nullable<INodeInfo> = null;
|
||||
|
||||
if (editArea === DocumentEditArea.HEADER || editArea === DocumentEditArea.FOOTER) {
|
||||
const headerSke = skeHeaders.get(headerId)?.get(pageWidth) as IDocumentSkeletonPage;
|
||||
const headerSke = skeHeaders.get(headerId)?.get(page.headerLayoutKey ?? pageWidth) as IDocumentSkeletonPage;
|
||||
|
||||
if (headerSke) {
|
||||
exactMatch = this._collectNearestNode(
|
||||
@@ -3411,7 +3431,7 @@ export class DocumentSkeleton extends Skeleton {
|
||||
);
|
||||
}
|
||||
|
||||
const footerSke = skeFooters.get(footerId)?.get(pageWidth) as IDocumentSkeletonPage;
|
||||
const footerSke = skeFooters.get(footerId)?.get(page.footerLayoutKey ?? pageWidth) as IDocumentSkeletonPage;
|
||||
|
||||
if (footerSke) {
|
||||
exactMatch = exactMatch ?? this._collectNearestNode(
|
||||
@@ -3470,7 +3490,7 @@ export class DocumentSkeleton extends Skeleton {
|
||||
const { headerId, footerId, pageWidth } = page;
|
||||
|
||||
if (segmentId !== '' && !this._docViewModel.getNoteTreeMap().has(segmentId)) {
|
||||
const headerSke = skeHeaders.get(headerId)?.get(pageWidth) as IDocumentSkeletonPage;
|
||||
const headerSke = skeHeaders.get(headerId)?.get(page.headerLayoutKey ?? pageWidth) as IDocumentSkeletonPage;
|
||||
|
||||
if (headerSke) {
|
||||
exactMatch = this._collectNearestNode(
|
||||
@@ -3486,7 +3506,7 @@ export class DocumentSkeleton extends Skeleton {
|
||||
);
|
||||
}
|
||||
|
||||
const footerSke = skeFooters.get(footerId)?.get(pageWidth) as IDocumentSkeletonPage;
|
||||
const footerSke = skeFooters.get(footerId)?.get(page.footerLayoutKey ?? pageWidth) as IDocumentSkeletonPage;
|
||||
|
||||
if (footerSke) {
|
||||
exactMatch = exactMatch ?? this._collectNearestNode(
|
||||
@@ -3534,7 +3554,7 @@ export class DocumentSkeleton extends Skeleton {
|
||||
|
||||
const { headerId, pageWidth } = page;
|
||||
|
||||
const segmentSke = segmentId === headerId ? skeHeaders.get(segmentId)?.get(pageWidth) : skeFooters.get(segmentId)?.get(pageWidth);
|
||||
const segmentSke = segmentId === headerId ? skeHeaders.get(segmentId)?.get(page.headerLayoutKey ?? pageWidth) : skeFooters.get(segmentId)?.get(page.footerLayoutKey ?? pageWidth);
|
||||
if (segmentSke) {
|
||||
exactMatch = this._collectNearestNode(
|
||||
segmentSke,
|
||||
@@ -4097,6 +4117,7 @@ export class DocumentSkeleton extends Skeleton {
|
||||
return {
|
||||
generation: state.generation,
|
||||
publicationRevision: state.publicationRevision,
|
||||
paginationRevision: state.paginationRevision,
|
||||
didPublish,
|
||||
didPublishAnchor,
|
||||
publishedPageCount: state.lastPublishedPageCount,
|
||||
@@ -5058,11 +5079,59 @@ export class DocumentSkeleton extends Skeleton {
|
||||
updateInlineDrawingCoordsAndBorder(ctx, [page]);
|
||||
}
|
||||
}
|
||||
const retry = this._prepareFieldCountRetry(ctx);
|
||||
if (retry != null) {
|
||||
state.paginationRevision++;
|
||||
state.ctx = retry;
|
||||
state.sectionIndex = 0;
|
||||
state.paragraphIndex = 0;
|
||||
state.sectionInitialized = false;
|
||||
state.sectionBreakConfig = null;
|
||||
state.layoutAnchor = null;
|
||||
state.laidOutThrough = -1;
|
||||
state.stableLaidOutThrough = -1;
|
||||
state.processedBlockCount = 0;
|
||||
state.pendingTableBuild = null;
|
||||
state.pendingSlicedTableBuild = null;
|
||||
state.pendingParagraphCheckpoint = null;
|
||||
state.stablePageCount = 0;
|
||||
state.finalizedPageCount = 0;
|
||||
state.lastPublishedPageCount = 0;
|
||||
state.lastPublishedBlockCount = 0;
|
||||
state.anchorPublished = false;
|
||||
state.reuseUnaffectedTail = false;
|
||||
state.reusedTail = false;
|
||||
state.tailConvergencePageCount = 0;
|
||||
state.interactionPageTail = null;
|
||||
state.interactionPageComplete = false;
|
||||
state.interactionWindowComplete = false;
|
||||
state.interactionWindowResume = null;
|
||||
return;
|
||||
}
|
||||
setPageParent(skeleton.pages, skeleton);
|
||||
state.complete = true;
|
||||
state.stableLaidOutThrough = state.laidOutThrough;
|
||||
}
|
||||
|
||||
getCompletedPageCount(): number | undefined {
|
||||
return this._completedPageCount;
|
||||
}
|
||||
|
||||
private _prepareFieldCountRetry(ctx: ILayoutContext): ILayoutContext | null {
|
||||
if (!ctx.hasNumPagesFields || ctx.fieldPageCount === ctx.skeleton.pages.length) {
|
||||
return null;
|
||||
}
|
||||
const retry = this._prepareLayoutContext();
|
||||
retry.fieldPageCount = ctx.skeleton.pages.length;
|
||||
retry.headerFooterMinimumMargins = ctx.headerFooterMinimumMargins;
|
||||
retry.fieldCountCandidates = ctx.fieldCountCandidates ?? new Set();
|
||||
if (ctx.fieldPageCount != null) {
|
||||
retry.fieldCountCandidates.add(ctx.fieldPageCount);
|
||||
}
|
||||
retry.fieldCountCycle = ctx.fieldCountCycle || retry.fieldCountCandidates.has(retry.fieldPageCount);
|
||||
return retry;
|
||||
}
|
||||
|
||||
private _prepareLayoutContext(): ILayoutContext {
|
||||
const viewModel = this.getViewModel();
|
||||
const dataModel = viewModel.getDataModel();
|
||||
@@ -5107,7 +5176,13 @@ export class DocumentSkeleton extends Skeleton {
|
||||
drawingAnchor,
|
||||
};
|
||||
|
||||
const hasNumPagesFields = [...headerTreeMap.values(), ...footerTreeMap.values()].some((story) =>
|
||||
story.getBody()?.customRanges?.some((range) => range.rangeType === CustomRangeType.FIELD &&
|
||||
range.properties?.fieldType === 'NUMPAGES' && range.properties.locked !== BooleanNumber.TRUE));
|
||||
const ctx: ILayoutContext = {
|
||||
fieldPageCount: this.getCompletedPageCount(),
|
||||
hasNumPagesFields,
|
||||
headerFooterMinimumMargins: hasNumPagesFields ? new Map() : undefined,
|
||||
viewModel,
|
||||
dataModel,
|
||||
noteReferences: documentStyle.documentFlavor === DocumentFlavor.TRADITIONAL
|
||||
@@ -5498,8 +5573,8 @@ export class DocumentSkeleton extends Skeleton {
|
||||
let segmentPage = page;
|
||||
|
||||
if (segmentId) {
|
||||
const maybeHeaderSke = skeHeaders.get(segmentId)?.get(pageWidth);
|
||||
const maybeFooterSke = skeFooters.get(segmentId)?.get(pageWidth);
|
||||
const maybeHeaderSke = skeHeaders.get(segmentId)?.get(page.headerLayoutKey ?? pageWidth);
|
||||
const maybeFooterSke = skeFooters.get(segmentId)?.get(page.footerLayoutKey ?? pageWidth);
|
||||
const note = page.notes?.find((fragment) => fragment.noteId === segmentId &&
|
||||
fragment.page.st <= charIndex && charIndex <= fragment.page.ed);
|
||||
if (note) {
|
||||
|
||||
@@ -30,8 +30,8 @@ export interface IDocumentLayoutPagePublication {
|
||||
|
||||
export interface IDocumentLayoutResourcePublication {
|
||||
reset: boolean;
|
||||
skeHeaders: Array<[string, Array<[number, IDocumentSkeletonPagePatch]>]>;
|
||||
skeFooters: Array<[string, Array<[number, IDocumentSkeletonPagePatch]>]>;
|
||||
skeHeaders: Array<[string, Array<[number | string, IDocumentSkeletonPagePatch]>]>;
|
||||
skeFooters: Array<[string, Array<[number | string, IDocumentSkeletonPagePatch]>]>;
|
||||
skeListLevel: IDocumentLayoutListLevelPublication | null;
|
||||
drawingAnchor: IDocumentLayoutDrawingAnchorPublication | null;
|
||||
}
|
||||
|
||||
@@ -54,6 +54,8 @@ export interface IDocumentLayoutApplyResult {
|
||||
export interface IDocumentLayoutProgress {
|
||||
generation: number;
|
||||
publicationRevision: number;
|
||||
/** Pagination may restart when a total-page field changes the available body space. */
|
||||
paginationRevision?: number;
|
||||
didPublish: boolean;
|
||||
/** The publication atomically replaced the page containing the edit anchor. */
|
||||
didPublishAnchor: boolean;
|
||||
|
||||
@@ -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 type { ICustomRange } from '@univerjs/core';
|
||||
import type { IDocumentSkeletonGlyph } from '../../../basics/i-document-skeleton-cached';
|
||||
import { BooleanNumber, CustomRangeType, DataStreamTreeTokenType } from '@univerjs/core';
|
||||
|
||||
export function resolveHeaderFooterFieldGlyph(
|
||||
glyph: IDocumentSkeletonGlyph,
|
||||
startIndex: number,
|
||||
endIndex: number,
|
||||
customRanges: ICustomRange[],
|
||||
pageNumber: number,
|
||||
pageCount: number | undefined
|
||||
): IDocumentSkeletonGlyph {
|
||||
if (!glyph.content || glyph.raw === DataStreamTreeTokenType.CUSTOM_RANGE_START ||
|
||||
glyph.raw === DataStreamTreeTokenType.CUSTOM_RANGE_END) {
|
||||
return glyph;
|
||||
}
|
||||
const fieldRange = customRanges.find((customRange) =>
|
||||
customRange.rangeType === CustomRangeType.FIELD &&
|
||||
customRange.startIndex <= startIndex &&
|
||||
customRange.endIndex >= endIndex &&
|
||||
typeof customRange.properties?.fieldType === 'string'
|
||||
);
|
||||
const fieldType = fieldRange?.properties?.fieldType?.toUpperCase();
|
||||
if (!fieldRange || fieldRange.properties?.locked === BooleanNumber.TRUE || (fieldType !== 'PAGE' && fieldType !== 'NUMPAGES')) {
|
||||
return glyph;
|
||||
}
|
||||
if (fieldType === 'NUMPAGES' && pageCount == null) {
|
||||
return glyph;
|
||||
}
|
||||
// FIELD endpoints are invisible sentinels. Paint the value once even when
|
||||
// the cached digits were split into several differently formatted runs.
|
||||
let content = '';
|
||||
if (startIndex === fieldRange.startIndex + 1) {
|
||||
content = String(fieldType === 'PAGE' ? pageNumber : pageCount);
|
||||
}
|
||||
|
||||
return content === glyph.content ? glyph : { ...glyph, content };
|
||||
}
|
||||
@@ -105,8 +105,8 @@ describe('page model', () => {
|
||||
isDirty: false,
|
||||
} as any;
|
||||
|
||||
const headerVM = { getChildren: () => [{}], getParagraph: () => undefined };
|
||||
const footerVM = { getChildren: () => [{}], getParagraph: () => undefined };
|
||||
const headerVM = { getBody: () => undefined, getChildren: () => [{}], getParagraph: () => undefined };
|
||||
const footerVM = { getBody: () => undefined, getChildren: () => [{}], getParagraph: () => undefined };
|
||||
const sectionBreakConfig = {
|
||||
sectionId: 'section-page-model',
|
||||
pageNumberStart: 1,
|
||||
@@ -206,8 +206,8 @@ describe('page model', () => {
|
||||
pageSize: { width: 200, height: 300 },
|
||||
headerIds: { defaultHeaderId: 'h-default' },
|
||||
footerIds: { defaultFooterId: 'f-default' },
|
||||
headerTreeMap: new Map([['h-default', { getChildren: () => [{}], getParagraph: () => undefined }]]),
|
||||
footerTreeMap: new Map([['f-default', { getChildren: () => [{}], getParagraph: () => undefined }]]),
|
||||
headerTreeMap: new Map([['h-default', { getBody: () => undefined, getChildren: () => [{}], getParagraph: () => undefined }]]),
|
||||
footerTreeMap: new Map([['f-default', { getBody: () => undefined, getChildren: () => [{}], getParagraph: () => undefined }]]),
|
||||
columnProperties: [],
|
||||
marginTop: 40,
|
||||
marginBottom: 40,
|
||||
|
||||
@@ -26,6 +26,7 @@ import type { DocumentViewModel } from '../../view-model/document-view-model';
|
||||
import type { ILayoutContext } from '../tools';
|
||||
import {
|
||||
BooleanNumber,
|
||||
CustomRangeType,
|
||||
GridType,
|
||||
PageOrientType,
|
||||
PositionedObjectLayoutType,
|
||||
@@ -122,40 +123,70 @@ export function createSkeletonPage(
|
||||
let header: Nullable<IDocumentSkeletonHeaderFooter>;
|
||||
let footer: Nullable<IDocumentSkeletonHeaderFooter>;
|
||||
if (headerId) {
|
||||
if (skeHeaders.get(headerId)?.has(pageWidth)) {
|
||||
header = skeHeaders.get(headerId)?.get(pageWidth);
|
||||
const fieldRanges = headerTreeMap?.get(headerId)?.getBody()?.customRanges;
|
||||
const dynamic = fieldRanges?.some((range) => range.rangeType === CustomRangeType.FIELD &&
|
||||
range.properties?.locked !== BooleanNumber.TRUE &&
|
||||
(range.properties?.fieldType === 'PAGE' || range.properties?.fieldType === 'NUMPAGES'));
|
||||
const key = dynamic ? JSON.stringify([sectionId, pageNumber, pageWidth, marginLeft, marginRight, ctx.hasNumPagesFields ? ctx.fieldPageCount : undefined]) : pageWidth;
|
||||
if (typeof key === 'string') {
|
||||
page.headerLayoutKey = key;
|
||||
}
|
||||
if (skeHeaders.get(headerId)?.has(key)) {
|
||||
header = skeHeaders.get(headerId)?.get(key);
|
||||
} else if (headerTreeMap && headerTreeMap.has(headerId)) {
|
||||
header = _createSkeletonHeaderFooter(
|
||||
ctx,
|
||||
headerTreeMap.get(headerId)!,
|
||||
sectionBreakConfig,
|
||||
skeletonResourceReference,
|
||||
headerId,
|
||||
true
|
||||
);
|
||||
const previousFieldContext = ctx.headerFooterFieldContext;
|
||||
ctx.headerFooterFieldContext = dynamic ? { pageNumber, pageCount: ctx.fieldPageCount } : undefined;
|
||||
try {
|
||||
header = _createSkeletonHeaderFooter(
|
||||
ctx,
|
||||
headerTreeMap.get(headerId)!,
|
||||
sectionBreakConfig,
|
||||
skeletonResourceReference,
|
||||
headerId,
|
||||
true
|
||||
);
|
||||
header.fieldsResolved = Boolean(dynamic);
|
||||
} finally {
|
||||
ctx.headerFooterFieldContext = previousFieldContext;
|
||||
}
|
||||
|
||||
const widths = skeHeaders.get(headerId) ?? new Map();
|
||||
widths.set(pageWidth, header);
|
||||
widths.set(key, header);
|
||||
skeHeaders.set(headerId, widths);
|
||||
}
|
||||
page.headerId = headerId;
|
||||
}
|
||||
|
||||
if (footerId) {
|
||||
if (skeFooters.get(footerId)?.has(pageWidth)) {
|
||||
footer = skeFooters.get(footerId)?.get(pageWidth);
|
||||
const fieldRanges = footerTreeMap?.get(footerId)?.getBody()?.customRanges;
|
||||
const dynamic = fieldRanges?.some((range) => range.rangeType === CustomRangeType.FIELD &&
|
||||
range.properties?.locked !== BooleanNumber.TRUE &&
|
||||
(range.properties?.fieldType === 'PAGE' || range.properties?.fieldType === 'NUMPAGES'));
|
||||
const key = dynamic ? JSON.stringify([sectionId, pageNumber, pageWidth, marginLeft, marginRight, ctx.hasNumPagesFields ? ctx.fieldPageCount : undefined]) : pageWidth;
|
||||
if (typeof key === 'string') {
|
||||
page.footerLayoutKey = key;
|
||||
}
|
||||
if (skeFooters.get(footerId)?.has(key)) {
|
||||
footer = skeFooters.get(footerId)?.get(key);
|
||||
} else if (footerTreeMap && footerTreeMap.has(footerId)) {
|
||||
footer = _createSkeletonHeaderFooter(
|
||||
ctx,
|
||||
footerTreeMap.get(footerId)!,
|
||||
sectionBreakConfig,
|
||||
skeletonResourceReference,
|
||||
footerId,
|
||||
false
|
||||
);
|
||||
const previousFieldContext = ctx.headerFooterFieldContext;
|
||||
ctx.headerFooterFieldContext = dynamic ? { pageNumber, pageCount: ctx.fieldPageCount } : undefined;
|
||||
try {
|
||||
footer = _createSkeletonHeaderFooter(
|
||||
ctx,
|
||||
footerTreeMap.get(footerId)!,
|
||||
sectionBreakConfig,
|
||||
skeletonResourceReference,
|
||||
footerId,
|
||||
false
|
||||
);
|
||||
footer.fieldsResolved = Boolean(dynamic);
|
||||
} finally {
|
||||
ctx.headerFooterFieldContext = previousFieldContext;
|
||||
}
|
||||
|
||||
const widths = skeFooters.get(footerId) ?? new Map();
|
||||
widths.set(pageWidth, footer);
|
||||
widths.set(key, footer);
|
||||
skeFooters.set(footerId, widths);
|
||||
}
|
||||
page.footerId = footerId;
|
||||
@@ -165,6 +196,23 @@ export function createSkeletonPage(
|
||||
page.originMarginBottom = marginBottom;
|
||||
page.marginTop = _getVerticalMargin(marginTop, header);
|
||||
page.marginBottom = _getVerticalMargin(marginBottom, footer);
|
||||
// Only retain the larger reservation if proportional digits cause a page-count cycle.
|
||||
if (ctx.headerFooterMinimumMargins) {
|
||||
for (const [id, layoutKey, margin] of [
|
||||
[headerId, page.headerLayoutKey, 'marginTop'],
|
||||
[footerId, page.footerLayoutKey, 'marginBottom'],
|
||||
] as const) {
|
||||
if (layoutKey == null) {
|
||||
continue;
|
||||
}
|
||||
const key = JSON.stringify([id, sectionId, pageNumber, pageWidth, marginLeft, marginRight, margin]);
|
||||
const maximum = Math.max(page[margin], ctx.headerFooterMinimumMargins.get(key) ?? 0);
|
||||
ctx.headerFooterMinimumMargins.set(key, maximum);
|
||||
if (ctx.fieldCountCycle) {
|
||||
page[margin] = maximum;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sections = page.sections;
|
||||
const lastSection = sections[sections.length - 1];
|
||||
|
||||
@@ -956,7 +956,7 @@ export interface IDocumentSkeletonLineContext {
|
||||
}
|
||||
|
||||
export type DocumentSkeletonTableSource = 'page' | 'table-cell' | 'column' | 'header' | 'footer';
|
||||
type HeaderFooterSkeletonMap = Map<string, Map<number, IDocumentSkeletonPage>>;
|
||||
type HeaderFooterSkeletonMap = Map<string, Map<number | string, IDocumentSkeletonPage>>;
|
||||
|
||||
export interface IDocumentSkeletonTableCellGeometry {
|
||||
cell: IDocumentSkeletonPage;
|
||||
@@ -1036,7 +1036,7 @@ export function documentSkeletonTableIterator(
|
||||
});
|
||||
|
||||
const rootPageDocumentLeft = docsLeft + rootPage.marginLeft;
|
||||
const headerPage = rootPage.headerId == null ? undefined : skeHeaders?.get(rootPage.headerId)?.get(rootPage.pageWidth);
|
||||
const headerPage = rootPage.headerId == null ? undefined : skeHeaders?.get(rootPage.headerId)?.get(rootPage.headerLayoutKey ?? rootPage.pageWidth);
|
||||
if (headerPage != null) {
|
||||
collectPageTables({
|
||||
contexts,
|
||||
@@ -1054,7 +1054,7 @@ export function documentSkeletonTableIterator(
|
||||
});
|
||||
}
|
||||
|
||||
const footerPage = rootPage.footerId == null ? undefined : skeFooters?.get(rootPage.footerId)?.get(rootPage.pageWidth);
|
||||
const footerPage = rootPage.footerId == null ? undefined : skeFooters?.get(rootPage.footerId)?.get(rootPage.footerLayoutKey ?? rootPage.pageWidth);
|
||||
if (footerPage != null) {
|
||||
collectPageTables({
|
||||
contexts,
|
||||
@@ -1954,6 +1954,12 @@ export interface IDocumentPaginationMetrics {
|
||||
}
|
||||
|
||||
export interface ILayoutContext {
|
||||
headerFooterFieldContext?: { pageNumber: number; pageCount?: number };
|
||||
fieldPageCount?: number;
|
||||
hasNumPagesFields?: boolean;
|
||||
fieldCountCandidates?: Set<number>;
|
||||
fieldCountCycle?: boolean;
|
||||
headerFooterMinimumMargins?: Map<string, number>;
|
||||
noteReferences?: ReadonlyMap<number, INoteReferenceLayout>;
|
||||
footnoteLayout?: DocumentFootnoteLayout;
|
||||
endnoteLayout?: DocumentEndnoteLayout;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user