perf(docs): keep large documents responsive (#7615)

This commit is contained in:
Univer
2026-09-03 01:24:36 +08:00
committed by GitHub
parent 9c11175c70
commit 0fe9019def
132 changed files with 8292 additions and 1101 deletions
@@ -18,6 +18,7 @@ import type { IDocumentBody } from '../../../../types/interfaces/i-document-data
import { describe, expect, it } from 'vitest';
import { UpdateDocsAttributeType } from '../../../../shared/command-enum';
import { BooleanNumber } from '../../../../types/enum/text-style';
import { CustomDecorationType, DocumentBlockRangeType } from '../../../../types/interfaces/i-document-data';
import { TextXActionType } from '../action-types';
import { TextX } from '../text-x';
@@ -221,6 +222,78 @@ describe('test TextX methods and branches', () => {
});
describe('test TextX static methods', () => {
it.each([
{
name: 'column group',
offset: 2,
body: {
dataStream: '\x12\x13Field\r\n\x14\x15\r\n',
columnGroups: [{ columnGroupId: 'columns-1', startIndex: 0, endIndex: 10 }],
},
},
{
name: 'quote block',
offset: 1,
body: {
dataStream: '\x10Field\r\x11\r\n',
blockRanges: [{ blockId: 'quote-1', blockType: DocumentBlockRangeType.QUOTE, startIndex: 0, endIndex: 7 }],
},
},
])('does not clip an enclosing $name when undoing formatting', ({ body, offset }) => {
const original = JSON.parse(JSON.stringify(body)) as IDocumentBody;
const actions = new TextX().retain(offset).retain(3, {
dataStream: '',
textRuns: [{ st: 0, ed: 3, ts: { bl: BooleanNumber.TRUE } }],
}).serialize();
TextX.makeInvertible(actions, body);
TextX.apply(body, actions);
TextX.apply(body, TextX.invert(actions));
expect(body.columnGroups).toEqual(original.columnGroups);
expect(body.blockRanges).toEqual(original.blockRanges);
expect(body.dataStream).toBe(original.dataStream);
});
it('still restores table metadata when the retain explicitly changes it', () => {
const dataStream = '\x1A\x1B\x1CField\r\n\x1D\x0E\x0F\r\n';
const table = { tableId: 'table-1', startIndex: 0, endIndex: dataStream.length - 2 };
const body: IDocumentBody = { dataStream, tables: [table] };
const actions = new TextX().retain(table.endIndex, {
dataStream: '',
tables: [{ ...table, tableId: 'table-2' }],
}).serialize();
TextX.makeInvertible(actions, body);
TextX.apply(body, actions);
TextX.apply(body, TextX.invert(actions));
expect(body.tables).toEqual([table]);
});
it('keeps enclosing table metadata intact when undoing and redoing a comment inside a cell', () => {
const dataStream = '\x1A\x1B\x1CField\r\n\x1D\x0E\x0F\r\n';
const tables = [{ tableId: 'table-1', startIndex: 0, endIndex: dataStream.length - 2 }];
const body: IDocumentBody = { dataStream, tables: [...tables], customDecorations: [] };
const actions = new TextX().retain(3).retain(3, {
dataStream: '',
customDecorations: [{ id: 'comment-1', type: CustomDecorationType.COMMENT, startIndex: 0, endIndex: 2 }],
}).serialize();
TextX.makeInvertible(actions, body);
TextX.apply(body, actions);
const undoActions = TextX.invert(actions);
TextX.makeInvertible(undoActions, body);
TextX.apply(body, undoActions);
expect(body.tables).toEqual(tables);
expect(body.customDecorations).toEqual([]);
const redoActions = TextX.invert(undoActions);
TextX.apply(body, redoActions);
expect(body.tables).toEqual(tables);
expect(body.dataStream).toBe(dataStream);
expect(body.customDecorations).toEqual([
expect.objectContaining({ id: 'comment-1', startIndex: 3, endIndex: 5 }),
]);
});
it('test TextX isNoop method', () => {
const textX = new TextX();
@@ -303,6 +303,15 @@ export class TextX {
if (action.t === TextXActionType.RETAIN && action.body != null) {
const body = getBodySlice(doc, index, index + action.len, true);
// A partial slice clips enclosing structural ranges. Restoring
// those ranges would duplicate/truncate a table (or container)
// when undoing an unrelated comment or text-formatting change.
for (const field of ['tables', 'columnGroups', 'blockRanges'] as const) {
if (action.body[field] == null) {
delete body[field];
}
}
action.oldBody = {
...body,
dataStream: '',
@@ -35,6 +35,7 @@ export function Dropdown(props: IDropdownProps) {
disabled,
open: controlledOpen,
onOpenChange: controlledOnOpenChange,
forceMount,
...restProps
} = props;
@@ -72,9 +73,11 @@ export function Dropdown(props: IDropdownProps) {
<PopoverTrigger asChild>
{children}
</PopoverTrigger>
<PopoverContent {...restProps}>
{overlay}
</PopoverContent>
{(open || forceMount) && (
<PopoverContent forceMount={forceMount} {...restProps}>
{overlay}
</PopoverContent>
)}
</PopoverPrimitive>
);
}
@@ -56,6 +56,7 @@ function PopoverContent({
data-[side=top]:univer-slide-in-from-bottom-2
univer-z-[1080] univer-max-h-[--radix-popper-available-height] univer-overflow-y-auto
univer-rounded-md univer-bg-gray-0 univer-text-gray-900 univer-shadow-md
data-[state=closed]:univer-pointer-events-none
dark:!univer-bg-gray-900 dark:!univer-text-gray-0
dark:focus-visible:!univer-border-gray-600
`,
@@ -46,6 +46,23 @@ describe('Dropdown', () => {
expect(getByText('Overlay Content')).toBeTruthy();
});
it('should stop intercepting pointer input as soon as the overlay closes', () => {
const { rerender } = render(
<Dropdown overlay={<div>Overlay Content</div>} open>
<button type="button">Trigger</button>
</Dropdown>
);
expect(screen.getByText('Overlay Content').closest('[data-slot="popover-content"]')?.classList.contains('data-[state=closed]:univer-pointer-events-none')).toBe(true);
rerender(
<Dropdown overlay={<div>Overlay Content</div>} open={false}>
<button type="button">Trigger</button>
</Dropdown>
);
expect(screen.queryByText('Overlay Content')).toBeNull();
});
it('should call onOpenChange when trigger is clicked', () => {
const handleOpenChange = vi.fn();
const { getByText } = render(
@@ -17,6 +17,7 @@
import type { Dependency, DependencyIdentifier, DocumentDataModel, ICommand, IDocumentData } from '@univerjs/core';
import type { IDocImage, IInsertDocDrawingCommandParams, ISetDocDrawingArrangeCommandParams, IUpdateDrawingDocTransformCommandParams } from '@univerjs/docs-drawing';
import {
AlignTypeH,
ArrangeTypeEnum,
awaitTime,
BooleanNumber,
@@ -759,7 +760,9 @@ describe('docs drawing commands integration', () => {
it('moves a focused floating drawing by updating the persisted doc transform', async () => {
const testBed = setupDrawingTestBed(createDrawingDocData());
vi.spyOn(testBed.get(DocSkeletonManagerService), 'getSkeleton').mockReturnValue({} as never);
vi.spyOn(testBed.get(DocSkeletonManagerService), 'getSkeleton').mockReturnValue({
getSkeletonData: () => null,
} as never);
testBed.docDrawingService.focusDrawing([{ unitId: 'test-doc', subUnitId: 'test-doc', drawingId: 'shape-1' }]);
@@ -777,6 +780,97 @@ describe('docs drawing commands integration', () => {
testBed.univer.dispose();
});
it('converts aligned drawing positions to offsets before an arrow-key move', async () => {
const data = createDrawingDocData();
data.drawings!['shape-1'].docTransform.positionH = {
relativeFrom: ObjectRelativeFromH.PAGE,
align: AlignTypeH.CENTER,
};
data.drawings!['shape-1'].docTransform.positionV = {
relativeFrom: ObjectRelativeFromV.PAGE,
posOffset: 20,
};
const testBed = setupDrawingTestBed(data);
vi.spyOn(testBed.get(DocSkeletonManagerService), 'getSkeleton').mockReturnValue({
getSkeletonData: () => ({
pages: [{
footerId: '',
headerId: '',
marginBottom: 72,
marginLeft: 90,
marginTop: 72,
pageHeight: 840,
pageWidth: 594,
skeDrawings: new Map([['shape-1', {
aLeft: 247,
aTop: 10,
blockAnchorTop: 0,
columnLeft: 90,
lineTop: 0,
}]]),
skeTables: new Map(),
}],
skeFooters: new Map(),
skeHeaders: new Map(),
}),
} as never);
testBed.docDrawingService.focusDrawing([{ unitId: 'test-doc', subUnitId: 'test-doc', drawingId: 'shape-1' }]);
expect(await testBed.commandService.executeCommand(MoveDocDrawingsCommand.id, {
direction: Direction.RIGHT,
})).toBe(true);
const doc = testBed.get(IUniverInstanceService)
.getUnit<DocumentDataModel>('test-doc', UniverInstanceType.UNIVER_DOC)!;
expect(doc.getSnapshot().drawings?.['shape-1'].docTransform.positionH).toEqual({
relativeFrom: ObjectRelativeFromH.PAGE,
posOffset: 249,
});
testBed.univer.dispose();
});
it('does not move a drawing that only exists on a layout preview page', async () => {
const data = createDrawingDocData();
const originalTransform = structuredClone(data.drawings!['shape-1'].docTransform);
const testBed = setupDrawingTestBed(data);
vi.spyOn(testBed.get(DocSkeletonManagerService), 'getSkeleton').mockReturnValue({
getSkeletonData: () => ({
pages: [{
footerId: '',
headerId: '',
isLayoutPlaceholder: true,
marginBottom: 72,
marginLeft: 90,
marginTop: 72,
pageHeight: 840,
pageWidth: 594,
skeDrawings: new Map([['shape-1', {
aLeft: 247,
aTop: 10,
blockAnchorTop: 0,
columnLeft: 90,
lineTop: 0,
}]]),
skeTables: new Map(),
}],
skeFooters: new Map(),
skeHeaders: new Map(),
}),
} as never);
testBed.docDrawingService.focusDrawing([{ unitId: 'test-doc', subUnitId: 'test-doc', drawingId: 'shape-1' }]);
expect(await testBed.commandService.executeCommand(MoveDocDrawingsCommand.id, {
direction: Direction.RIGHT,
})).toBe(false);
const doc = testBed.get(IUniverInstanceService)
.getUnit<DocumentDataModel>('test-doc', UniverInstanceType.UNIVER_DOC)!;
expect(doc.getSnapshot().drawings?.['shape-1'].docTransform).toEqual(originalTransform);
testBed.univer.dispose();
});
it('updates drawing wrap distances through the command pipeline', async () => {
const testBed = setupDrawingTestBed(createDrawingDocData());
@@ -1013,7 +1107,7 @@ describe('docs drawing commands integration', () => {
posOffset: 18,
});
expect(testBed.refreshControls).toHaveBeenCalled();
expect(refreshDrawings).toHaveBeenCalledWith(skeleton);
expect(refreshDrawings).not.toHaveBeenCalled();
testBed.univer.dispose();
});
@@ -16,6 +16,7 @@
import type { DocumentDataModel, IAccessor, ICommand, IObjectPositionH, IObjectPositionV } from '@univerjs/core';
import type { IDocDrawing, IDrawingDocTransform, IUpdateDrawingDocTransformCommandParams } from '@univerjs/docs-drawing';
import type { IDocumentSkeletonCached } from '@univerjs/engine-render';
import {
CommandType,
Direction,
@@ -24,8 +25,10 @@ import {
PositionedObjectLayoutType,
UniverInstanceType,
} from '@univerjs/core';
import { DocSkeletonManagerService } from '@univerjs/docs';
import { IDocDrawingService, UpdateDrawingDocTransformCommand } from '@univerjs/docs-drawing';
import { IRenderManagerService } from '@univerjs/engine-render';
import { findDrawingAnchorInPage, resolveDrawingAnchorOffsets } from '../../utils/drawing-anchor-position';
export interface IMoveDrawingsCommandParams {
direction: Direction;
@@ -58,6 +61,7 @@ export const MoveDocDrawingsCommand: ICommand = {
return false;
}
const transformer = scene.getTransformerByCreate();
const skeletonData = renderObject?.with(DocSkeletonManagerService).getSkeleton()?.getSkeletonData();
const documentDataModel = univerInstanceService.getUnit<DocumentDataModel>(unitId, UniverInstanceType.UNIVER_DOC);
@@ -71,9 +75,25 @@ export const MoveDocDrawingsCommand: ICommand = {
}
const { positionH, positionV } = drawingData.docTransform;
const anchorLookup = skeletonData == null
? { anchor: null, previewOnly: false }
: findDrawingAnchor(skeletonData, drawingId);
if (anchorLookup.previewOnly) {
return null;
}
const newPositionH: IObjectPositionH = { ...positionH };
const newPositionV: IObjectPositionV = { ...positionV };
const { anchor } = anchorLookup;
const offsets = anchor == null
? { horizontal: positionH.posOffset ?? 0, vertical: positionV.posOffset ?? 0 }
: resolveDrawingAnchorOffsets(anchor, positionH, positionV);
const newPositionH = {
...(positionH.relativeFrom == null ? {} : { relativeFrom: positionH.relativeFrom }),
posOffset: offsets.horizontal,
} as IObjectPositionH;
const newPositionV = {
...(positionV.relativeFrom == null ? {} : { relativeFrom: positionV.relativeFrom }),
posOffset: offsets.vertical,
} as IObjectPositionV;
if (direction === Direction.UP) {
newPositionV.posOffset = (newPositionV.posOffset ?? 0) - 2;
@@ -107,3 +127,39 @@ export const MoveDocDrawingsCommand: ICommand = {
return Boolean(result);
},
};
function findDrawingAnchor(
skeletonData: IDocumentSkeletonCached,
drawingId: string
) {
let foundInPreview = false;
for (const page of skeletonData.pages) {
const bodyAnchor = findDrawingAnchorInPage(page, drawingId, page.marginTop, page.marginLeft);
const header = page.headerId == null
? undefined
: skeletonData.skeHeaders.get(page.headerId)?.get(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);
const footerTop = footer == null ? 0 : page.pageHeight - page.marginBottom + footer.marginTop;
const footerAnchor = footer == null
? null
: findDrawingAnchorInPage(footer, drawingId, footerTop, page.marginLeft);
const anchor = bodyAnchor ?? headerAnchor ?? footerAnchor;
if (anchor == null) {
continue;
}
if (!page.isLayoutPlaceholder && !page.isMaterializationPlaceholder) {
return { anchor, previewOnly: false };
}
foundInPreview = true;
}
return { anchor: null, previewOnly: foundInPreview };
}
@@ -14,247 +14,294 @@
* limitations under the License.
*/
import type { DocumentDataModel, IDocumentData, JSONXActions } from '@univerjs/core';
import type { IDocDrawing } from '@univerjs/docs-drawing';
import type { RenderUnit } from '@univerjs/engine-render';
import {
BooleanNumber,
DocumentFlavor,
DrawingTypeEnum,
ICommandService,
IUndoRedoService,
IUniverInstanceService,
JSONX,
ObjectRelativeFromH,
ObjectRelativeFromV,
PositionedObjectLayoutType,
RedoCommand,
UndoCommand,
Univer,
UniverInstanceType,
} from '@univerjs/core';
import { RichTextEditingMutation } from '@univerjs/docs';
import { TextWrappingStyle, UpdateDocDrawingWrappingStyleCommand } from '@univerjs/docs-drawing';
import { DocumentEditArea } from '@univerjs/engine-render';
import { describe, expect, it, vi } from 'vitest';
import {
DocLayoutExecutorService,
DocSelectionManagerService,
DocSkeletonManagerService,
DocStateChangeManagerService,
DocStateEmitService,
RichTextEditingMutation,
} from '@univerjs/docs';
import {
DocDrawingAdapterService,
DocDrawingController,
DocDrawingService,
IDocDrawingAdapterService,
IDocDrawingService,
InsertDocDrawingCommand,
RemoveDocDrawingCommand,
TextWrappingStyle,
UpdateDocDrawingWrappingStyleCommand,
UpdateDrawingDocTransformCommand,
} from '@univerjs/docs-drawing';
import { DrawingManagerService, IDrawingManagerService } from '@univerjs/drawing';
import {
CanvasColorService,
ICanvasColorService,
IRenderManagerService,
RenderManagerService,
} from '@univerjs/engine-render';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { DocRefreshDrawingsService } from '../../services/doc-refresh-drawings.service';
import { DocDrawingAddRemoveController } from '../doc-drawing-notification.controller';
function createController() {
const beforeHandlers: Array<(command: { id: string; params?: unknown }) => void> = [];
const executedHandlers: Array<(command: { id: string; params?: unknown }) => void> = [];
const refreshControls = vi.fn();
const refreshDrawings = vi.fn();
const drawing = {
unitId: 'doc-1',
subUnitId: 'doc-1',
drawingId: 'drawing-1',
layoutType: PositionedObjectLayoutType.WRAP_NONE,
behindDoc: BooleanNumber.TRUE,
docTransform: {
size: { width: 100, height: 60 },
angle: 0,
positionH: { relativeFrom: ObjectRelativeFromH.MARGIN, posOffset: 1 },
positionV: { relativeFrom: ObjectRelativeFromV.PARAGRAPH, posOffset: 2 },
},
};
const skeleton = {
getSkeletonData: () => ({
pages: [{
marginTop: 20,
marginLeft: 30,
marginBottom: 20,
pageWidth: 600,
pageHeight: 800,
headerId: '',
footerId: '',
skeDrawings: new Map([['drawing-1', {
drawingId: 'drawing-1',
aLeft: 130,
aTop: 180,
columnLeft: 30,
lineTop: 150,
blockAnchorTop: 140,
drawingOrigin: drawing,
}]]),
skeTables: new Map(),
}],
skeHeaders: new Map(),
skeFooters: new Map(),
}),
};
const renderObject = {
scene: { getTransformerByCreate: () => ({ refreshControls }) },
with: () => ({
getSkeleton: () => skeleton,
getViewModel: () => ({ getEditArea: () => DocumentEditArea.BODY }),
}),
};
const drawingManagerService = {
applyJson1: vi.fn(),
addNotification: vi.fn(),
removeNotification: vi.fn(),
getFocusDrawings: vi.fn(() => [{ drawingId: 'drawing-1' }]),
setDrawingOrder: vi.fn(),
orderNotification: vi.fn(),
};
const docDrawingService = {
getBatchAddOp: vi.fn(() => ({ subUnitId: 'doc-1', redo: 'add-op', objects: [{ drawingId: 'drawing-1' }] })),
getBatchRemoveOp: vi.fn(() => ({ subUnitId: 'doc-1', redo: 'remove-op', objects: [{ drawingId: 'drawing-1' }] })),
applyJson1: vi.fn(),
addNotification: vi.fn(),
removeNotification: vi.fn(),
setDrawingOrder: vi.fn(),
orderNotification: vi.fn(),
};
const controller = new DocDrawingAddRemoveController(
{
getCurrentUnitOfType: vi.fn(() => ({ getUnitId: () => 'doc-1' })),
getUnit: vi.fn(() => ({
getDrawings: () => ({
'drawing-1': drawing,
'drawing-2': {
layoutType: PositionedObjectLayoutType.WRAP_NONE,
behindDoc: BooleanNumber.FALSE,
},
}),
getSnapshot: () => ({
drawings: {
'drawing-1': drawing,
'drawing-2': {
layoutType: PositionedObjectLayoutType.WRAP_NONE,
behindDoc: BooleanNumber.FALSE,
},
},
drawingsOrder: ['drawing-2', 'drawing-1'],
}),
})),
} as never,
{
beforeCommandExecuted: vi.fn((handler) => {
beforeHandlers.push(handler);
return { dispose: vi.fn() };
}),
onCommandExecuted: vi.fn((handler) => {
executedHandlers.push(handler);
return { dispose: vi.fn() };
}),
} as never,
drawingManagerService as never,
docDrawingService as never,
{
getRenderUnitById: vi.fn(() => renderObject),
} as never,
{ refreshDrawings } as never
);
describe('DocDrawingAddRemoveController with real commands and services', () => {
const unitId = 'drawing-notifications';
let univer: Univer;
let model: DocumentDataModel;
let render: RenderUnit;
let commands: ICommandService;
let manager: IDrawingManagerService;
let docDrawings: IDocDrawingService;
let refresh: DocRefreshDrawingsService;
return {
controller,
beforeHandlers,
executedHandlers,
drawingManagerService,
docDrawingService,
drawing,
refreshControls,
refreshDrawings,
skeleton,
};
}
describe('DocDrawingAddRemoveController', () => {
it('mirrors added and removed doc drawings into drawing services when rich text mutates drawings', () => {
const { controller, beforeHandlers, drawingManagerService, docDrawingService } = createController();
const drawing = { unitId: 'doc-1', subUnitId: 'doc-1', drawingId: 'drawing-1' };
beforeHandlers[0]({
id: RichTextEditingMutation.id,
params: {
unitId: 'doc-1',
actions: [['drawings', 'drawing-1', { i: drawing }]],
},
});
beforeHandlers[0]({
id: RichTextEditingMutation.id,
params: {
unitId: 'doc-1',
actions: [['drawings', 'drawing-1', { d: drawing }]],
},
});
expect(docDrawingService.getBatchAddOp).toHaveBeenCalledWith([drawing]);
expect(drawingManagerService.applyJson1).toHaveBeenCalledWith('doc-1', 'doc-1', 'add-op');
expect(docDrawingService.applyJson1).toHaveBeenCalledWith('doc-1', 'doc-1', 'add-op');
expect(drawingManagerService.addNotification).toHaveBeenCalledWith([{ drawingId: 'drawing-1' }]);
expect(docDrawingService.addNotification).toHaveBeenCalledWith([{ drawingId: 'drawing-1' }]);
expect(docDrawingService.getBatchRemoveOp).toHaveBeenCalledWith([{ unitId: 'doc-1', subUnitId: 'doc-1', drawingId: 'drawing-1' }]);
expect(drawingManagerService.removeNotification).toHaveBeenCalledWith([{ drawingId: 'drawing-1' }]);
expect(docDrawingService.removeNotification).toHaveBeenCalledWith([{ drawingId: 'drawing-1' }]);
controller.dispose();
});
it('updates drawing order after a reorder mutation', () => {
const { controller, executedHandlers, drawingManagerService, docDrawingService } = createController();
executedHandlers[0]({
id: RichTextEditingMutation.id,
params: {
unitId: 'doc-1',
actions: ['drawingsOrder', [0, { d: 0 }], [1, { p: 0 }]],
},
});
expect(drawingManagerService.setDrawingOrder).toHaveBeenCalledWith('doc-1', 'doc-1', ['drawing-1', 'drawing-2']);
expect(docDrawingService.setDrawingOrder).toHaveBeenCalledWith('doc-1', 'doc-1', ['drawing-2', 'drawing-1']);
expect(drawingManagerService.orderNotification).toHaveBeenCalledWith({
unitId: 'doc-1',
subUnitId: 'doc-1',
drawingIds: ['drawing-1', 'drawing-2'],
});
expect(docDrawingService.orderNotification).toHaveBeenCalledWith({
unitId: 'doc-1',
subUnitId: 'doc-1',
drawingIds: ['drawing-2', 'drawing-1'],
});
controller.dispose();
});
it.each([UndoCommand.id, RedoCommand.id])('refreshes drawings before controls after %s', (commandId) => {
const { controller, executedHandlers, refreshControls, refreshDrawings, skeleton } = createController();
executedHandlers.forEach((handler) => handler({ id: commandId }));
expect(refreshDrawings).toHaveBeenCalledExactlyOnceWith(skeleton);
expect(refreshControls).toHaveBeenCalledOnce();
expect(refreshDrawings.mock.invocationCallOrder[0]).toBeLessThan(refreshControls.mock.invocationCallOrder[0]);
controller.dispose();
});
it('preserves skeleton position before wrapping changes and refreshes drawings afterward', () => {
const {
controller,
beforeHandlers,
executedHandlers,
drawing,
refreshControls,
refreshDrawings,
skeleton,
} = createController();
const params = {
unitId: 'doc-1',
subUnitId: 'doc-1',
drawings: [{ drawingId: 'drawing-1' }],
wrappingStyle: TextWrappingStyle.WRAP_SQUARE,
};
const command = { id: UpdateDocDrawingWrappingStyleCommand.id, params };
beforeHandlers.forEach((handler) => handler(command));
expect(params.drawings[0]).toMatchObject({
drawingId: 'drawing-1',
function drawing(drawingId: string): IDocDrawing {
return {
unitId,
subUnitId: unitId,
drawingId,
drawingType: DrawingTypeEnum.DRAWING_IMAGE,
layoutType: PositionedObjectLayoutType.WRAP_NONE,
behindDoc: drawingId === 'a' ? BooleanNumber.TRUE : BooleanNumber.FALSE,
transform: { left: 20, top: 30, width: 40, height: 30 },
docTransform: {
positionH: { relativeFrom: ObjectRelativeFromH.MARGIN, posOffset: 100 },
positionV: { relativeFrom: ObjectRelativeFromV.PARAGRAPH, posOffset: 40 },
size: { width: 40, height: 30 },
angle: 0,
positionH: { relativeFrom: ObjectRelativeFromH.MARGIN, posOffset: 20 },
positionV: { relativeFrom: ObjectRelativeFromV.PARAGRAPH, posOffset: 30 },
},
};
}
beforeEach(() => {
const context = 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(context as never);
univer = new Univer();
const injector = univer.__getInjector();
injector.add([IRenderManagerService, { useClass: RenderManagerService }]);
injector.add([ICanvasColorService, { useClass: CanvasColorService }]);
injector.add([IDrawingManagerService, { useClass: DrawingManagerService }]);
injector.add([IDocDrawingService, { useClass: DocDrawingService }]);
injector.add([IDocDrawingAdapterService, { useClass: DocDrawingAdapterService }]);
injector.add([DocLayoutExecutorService]);
injector.add([DocSelectionManagerService]);
injector.add([DocStateEmitService]);
injector.add([DocStateChangeManagerService]);
injector.add([DocRefreshDrawingsService]);
injector.add([DocDrawingController]);
injector.add([DocDrawingAddRemoveController]);
commands = injector.get(ICommandService);
commands.registerCommand(RichTextEditingMutation);
injector.get(IUndoRedoService);
injector.get(DocStateChangeManagerService);
const drawingController = injector.get(DocDrawingController);
model = univer.createUnit<IDocumentData, DocumentDataModel>(UniverInstanceType.UNIVER_DOC, {
id: unitId,
body: {
dataStream: 'A\b\rB\b\r\n',
paragraphs: [{ startIndex: 2, paragraphId: 'p1' }, { startIndex: 5, paragraphId: 'p2' }],
sectionBreaks: [{ startIndex: 6, sectionId: 's1' }],
customBlocks: [{ startIndex: 1, blockId: 'a' }, { startIndex: 4, blockId: 'b' }],
},
drawings: { a: drawing('a'), b: drawing('b') },
drawingsOrder: ['a', 'b'],
documentStyle: {
documentFlavor: DocumentFlavor.TRADITIONAL,
autoHyphenation: BooleanNumber.FALSE,
pageSize: { width: 300, height: 400 },
marginTop: 20,
marginBottom: 20,
marginLeft: 20,
marginRight: 20,
},
});
expect(drawing.docTransform).toMatchObject({
positionH: { posOffset: 1 },
positionV: { posOffset: 2 },
injector.get(IUniverInstanceService).setCurrentUnitForType(unitId);
injector.get(IUniverInstanceService).focusUnit(unitId);
render = injector.get(IRenderManagerService).createRender(unitId) as RenderUnit;
render.deactivate();
render.addRenderDependencies([[DocSkeletonManagerService]]);
render.with(DocSkeletonManagerService);
drawingController.loadDrawingDataForUnit(unitId);
manager = injector.get(IDrawingManagerService);
docDrawings = injector.get(IDocDrawingService);
refresh = injector.get(DocRefreshDrawingsService);
injector.get(DocDrawingAddRemoveController);
});
afterEach(() => {
univer?.dispose();
vi.restoreAllMocks();
});
function mutate(actions: JSONXActions): void {
commands.syncExecuteCommand(RichTextEditingMutation.id, { unitId, actions, textRanges: null });
}
function resize(): void {
expect(commands.syncExecuteCommand(UpdateDrawingDocTransformCommand.id, {
unitId,
subUnitId: unitId,
drawings: [{ drawingId: 'a', key: 'size', value: { width: 60, height: 45 } }],
})).toBe(true);
}
it('keeps unrelated rendered positions and visibility when one drawing is resized', () => {
manager.refreshTransform([{ ...drawing('b'), hidden: true, transform: { left: 420, top: 880, width: 40, height: 30 } }]);
const before = manager.getDrawingByParam({ unitId, subUnitId: unitId, drawingId: 'b' });
const order = manager.getDrawingOrder(unitId, unitId);
resize();
expect(model.getDrawings()!.a.docTransform.size).toEqual({ width: 60, height: 45 });
expect(manager.getDrawingByParam({ unitId, subUnitId: unitId, drawingId: 'b' })).toBe(before);
expect(before).toMatchObject({ hidden: true, transform: { left: 420, top: 880 } });
expect(manager.getDrawingOrder(unitId, unitId)).toBe(order);
});
it('does not write layout-only transforms into the persisted drawing snapshot after a resize', () => {
resize();
const original = { ...model.getDrawings()!.a.transform };
manager.refreshTransform([{ ...drawing('a'), transform: { left: 720, top: 990, width: 60, height: 45 } }]);
expect(model.getDrawings()!.a.transform).toEqual(original);
});
it('keeps a thousand-drawing document stable during repeated single-image resizing', ({ task }) => {
const extraDrawings = Array.from({ length: 998 }, (_, index) => drawing(`extra-${index}`));
expect(commands.syncExecuteCommand(InsertDocDrawingCommand.id, {
unitId,
drawings: extraDrawings,
textRange: { startOffset: 0, endOffset: 0 },
})).toBe(true);
const original = manager.getDrawingByParam({ unitId, subUnitId: unitId, drawingId: 'extra-997' });
const order = manager.getDrawingOrder(unitId, unitId);
const mutationMs: number[] = [];
for (let index = 0; index < 20; index++) {
const start = performance.now();
commands.syncExecuteCommand(UpdateDrawingDocTransformCommand.id, {
unitId,
subUnitId: unitId,
drawings: [{ drawingId: 'a', key: 'size', value: { width: 60 + index, height: 45 } }],
});
mutationMs.push(performance.now() - start);
}
Object.assign(task.meta, { drawingCount: 1000, mutationMs });
expect(Object.keys(model.getDrawings()!)).toHaveLength(1000);
expect(model.getDrawings()!.a.docTransform.size.width).toBe(79);
expect(manager.getDrawingByParam({ unitId, subUnitId: unitId, drawingId: 'extra-997' })).toBe(original);
expect(manager.getDrawingOrder(unitId, unitId)).toBe(order);
});
it('mirrors actual drawing insertion and removal and emits their notifications', () => {
const added = vi.fn();
const removed = vi.fn();
const addSubscription = manager.add$.subscribe(added);
const removeSubscription = manager.remove$.subscribe(removed);
expect(commands.syncExecuteCommand(InsertDocDrawingCommand.id, {
unitId,
drawings: [drawing('c')],
textRange: { startOffset: 0, endOffset: 0 },
})).toBe(true);
expect(manager.getDrawingByParam({ unitId, subUnitId: unitId, drawingId: 'c' })).toBeDefined();
expect(added).toHaveBeenCalled();
expect(commands.syncExecuteCommand(RemoveDocDrawingCommand.id, {
unitId,
drawings: [{ unitId, subUnitId: unitId, drawingId: 'c', drawingType: DrawingTypeEnum.DRAWING_IMAGE }],
textRange: { startOffset: 0, endOffset: 0 },
})).toBe(true);
expect(manager.getDrawingByParam({ unitId, subUnitId: unitId, drawingId: 'c' })).toBeUndefined();
expect(docDrawings.getDrawingByParam({ unitId, subUnitId: unitId, drawingId: 'c' })).toBeUndefined();
expect(removed).toHaveBeenCalled();
addSubscription.unsubscribe();
removeSubscription.unsubscribe();
});
it('updates persisted order and keeps behind-text drawings below foreground drawings', () => {
mutate(JSONX.getInstance().moveOp(['drawingsOrder', 0], ['drawingsOrder', 1]));
expect(docDrawings.getDrawingOrder(unitId, unitId)).toEqual(['b', 'a']);
expect(manager.getDrawingOrder(unitId, unitId)).toEqual(['a', 'b']);
});
it('still rebuilds render order when wrapping changes the behind-text layer', () => {
mutate(JSONX.getInstance().moveOp(['drawingsOrder', 0], ['drawingsOrder', 1]));
expect(manager.getDrawingOrder(unitId, unitId)).toEqual(['a', 'b']);
expect(commands.syncExecuteCommand(UpdateDocDrawingWrappingStyleCommand.id, {
unitId,
subUnitId: unitId,
drawings: [{ drawingId: 'a' }],
wrappingStyle: TextWrappingStyle.IN_FRONT_OF_TEXT,
})).toBe(true);
expect(manager.getDrawingOrder(unitId, unitId)).toEqual(['b', 'a']);
expect(docDrawings.getDrawingOrder(unitId, unitId)).toEqual(['b', 'a']);
});
it('removes optional metadata from current data while retaining it for old-state comparisons', () => {
mutate(JSONX.getInstance().insertOp(['drawings', 'a', 'description'], 'caption'));
mutate(JSONX.getInstance().removeOp(['drawings', 'a', 'description'], 'caption'));
const search = { unitId, subUnitId: unitId, drawingId: 'a' };
expect(manager.getDrawingByParam(search)).not.toHaveProperty('description');
expect(manager.getOldDrawingByParam(search)).toHaveProperty('description', 'caption');
});
it('refreshes drawing positions before controls on actual undo and redo', async () => {
manager.focusDrawing([{ unitId, subUnitId: unitId, drawingId: 'a' }]);
resize();
const refreshDrawings = vi.spyOn(refresh, 'refreshDrawings');
const refreshControls = vi.spyOn(render.scene.getTransformerByCreate(), 'refreshControls');
for (const [commandId, expectedWidth] of [[UndoCommand.id, 40], [RedoCommand.id, 60]] as const) {
refreshDrawings.mockClear();
refreshControls.mockClear();
expect(await commands.executeCommand(commandId)).toBe(true);
expect(model.getDrawings()!.a.docTransform.size.width).toBe(expectedWidth);
expect(refreshDrawings).toHaveBeenCalledWith(render.with(DocSkeletonManagerService).getSkeleton());
expect(refreshDrawings.mock.invocationCallOrder[0]).toBeLessThan(refreshControls.mock.invocationCallOrder[0]);
}
});
it('preserves the resolved anchor when changing wrapping and refreshes the drawing afterward', () => {
const skeleton = render.with(DocSkeletonManagerService).getSkeleton();
const page = skeleton.getSkeletonData()!.pages[0];
const anchor = page.skeDrawings.get('a')!;
expect(anchor).toBeDefined();
const expectedLeft = anchor.aLeft - page.marginLeft;
const expectedTop = anchor.aTop - anchor.blockAnchorTop;
const refreshDrawings = vi.spyOn(refresh, 'refreshDrawings');
expect(commands.syncExecuteCommand(UpdateDocDrawingWrappingStyleCommand.id, {
unitId,
subUnitId: unitId,
drawings: [{ drawingId: 'a' }],
wrappingStyle: TextWrappingStyle.WRAP_SQUARE,
})).toBe(true);
expect(model.getDrawings()!.a.docTransform).toMatchObject({
positionH: { posOffset: expectedLeft },
positionV: { posOffset: expectedTop },
});
executedHandlers.forEach((handler) => handler(command));
expect(refreshDrawings).toHaveBeenCalledWith(skeleton);
expect(refreshControls).toHaveBeenCalled();
controller.dispose();
});
});
@@ -14,8 +14,37 @@
* limitations under the License.
*/
import { DOC_DRAWING_PRINTING_COMPONENT_KEY, DrawingTypeEnum } from '@univerjs/core';
import { describe, expect, it, vi } from 'vitest';
import type { DocumentDataModel, IDocumentData } from '@univerjs/core';
import type { IGalleryProps } from '@univerjs/design';
import type { IDocDrawing } from '@univerjs/docs-drawing';
import {
BooleanNumber,
DataStreamTreeTokenType,
DisposableCollection,
DOC_DRAWING_PRINTING_COMPONENT_KEY,
DocumentFlavor,
DrawingTypeEnum,
IImageIoService,
ImageSourceType,
IURLImageService,
LocaleService,
ObjectRelativeFromH,
ObjectRelativeFromV,
PositionedObjectLayoutType,
TableAlignmentType,
TableRowHeightRule,
TableSizeType,
TableTextWrapType,
Univer,
UniverInstanceType,
} from '@univerjs/core';
import { DocPrintInterceptorService } from '@univerjs/docs-ui';
import { DrawingManagerService, getDrawingShapeKeyByDrawingSearch, IDrawingManagerService } from '@univerjs/drawing';
import { DrawingImageClipService, DrawingRenderService } from '@univerjs/drawing-ui';
import { Documents, DocumentSkeleton, DocumentViewModel, Engine, Scene } from '@univerjs/engine-render';
import { ComponentManager, IGalleryService } from '@univerjs/ui';
import { EMPTY, Subject } from 'rxjs';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mountDocPrintingFloatDom } from '../../views/DocPrintingFloatDom';
import { DocDrawingPrintingController } from '../doc-drawing-printing.controller';
@@ -23,97 +52,265 @@ vi.mock('../../views/DocPrintingFloatDom', () => ({
mountDocPrintingFloatDom: vi.fn(() => vi.fn()),
}));
function createController() {
const interceptors = new Map<string, any>();
const points = {
PRINTING_COMPONENT_COLLECT: 'PRINTING_COMPONENT_COLLECT',
PRINTING_DOM_COLLECT: 'PRINTING_DOM_COLLECT',
};
const drawings = {
'unit-1': {
order: ['image-1', 'chart-1', 'dom-1'],
data: {
'image-1': { drawingId: 'image-1', drawingType: DrawingTypeEnum.DRAWING_IMAGE },
'chart-1': { drawingId: 'chart-1', drawingType: DrawingTypeEnum.DRAWING_CHART },
'dom-1': { drawingId: 'dom-1', drawingType: DrawingTypeEnum.DRAWING_DOM, componentKey: 'custom-dom' },
},
},
};
const controller = new DocDrawingPrintingController(
{
getPrintComponent: vi.fn((key: string) => `${key}-print`),
interceptor: {
getInterceptPoints: vi.fn(() => points),
intercept: vi.fn((point, config) => {
interceptors.set(point, config);
return { dispose: vi.fn() };
}),
},
} as never,
{ renderDrawing: vi.fn() } as never,
{ getDrawingDataForUnit: vi.fn(() => drawings) } as never,
{
get: vi.fn((key) => {
if (key === DOC_DRAWING_PRINTING_COMPONENT_KEY) return 'ChartPrintingComponent';
return `${key}-Component`;
describe('DocDrawingPrintingController with real layout and drawing services', () => {
let univer: Univer;
let engine: Engine;
let scene: Scene;
let skeleton: DocumentSkeleton;
let documents: Documents;
let printService: DocPrintInterceptorService;
const unitId = 'print-images';
const imageIds = ['body-first', 'cell-image', 'body-second', 'header-image', 'footer-image'];
beforeEach(() => {
const context = new Proxy({
font: '',
webkitBackingStorePixelRatio: 1,
measureText: (text: string) => ({
width: text.length * 8,
actualBoundingBoxAscent: 8,
actualBoundingBoxDescent: 2,
fontBoundingBoxAscent: 8,
fontBoundingBoxDescent: 2,
}),
} as never,
{} as never
);
}, { get: (target, key) => key in target ? Reflect.get(target, key) : () => {} });
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(context as never);
const cachedImage = document.createElement('img');
Object.defineProperties(cachedImage, {
complete: { value: true },
naturalWidth: { value: 16 },
naturalHeight: { value: 16 },
});
univer = new Univer();
const injector = univer.__getInjector();
injector.add([IDrawingManagerService, { useClass: DrawingManagerService }]);
injector.add([IImageIoService, { useValue: {
change$: EMPTY,
setWaitCount: () => {},
getImage: async () => '',
saveImage: async () => null,
getImageSourceCache: () => cachedImage,
addImageSourceCache: () => {},
} }]);
injector.add([IURLImageService, { useValue: {
getImage: async (url: string) => url,
downloadImage: async () => new Blob(),
registerURLImageDownloader: () => ({ dispose: () => {} }),
} }]);
injector.add([IGalleryService, { useValue: {
gallery$: new Subject<IGalleryProps>(),
open: () => ({ dispose: () => {} }),
close: () => {},
} }]);
injector.add([DrawingImageClipService]);
injector.add([DrawingRenderService]);
injector.add([ComponentManager]);
injector.add([DocPrintInterceptorService]);
injector.add([DocDrawingPrintingController]);
return { controller, interceptors, points };
}
describe('DocDrawingPrintingController', () => {
it('renders printable image drawings into the print scene and skips chart/dom canvas rendering', () => {
const { controller, interceptors, points } = createController();
const next = vi.fn();
const scene = {};
interceptors.get(points.PRINTING_COMPONENT_COLLECT).handler(null, { unitId: 'unit-1', scene }, next);
expect((controller as any)._drawingRenderService.renderDrawing).toHaveBeenCalledTimes(1);
expect((controller as any)._drawingRenderService.renderDrawing).toHaveBeenCalledWith(
expect.objectContaining({ drawingId: 'image-1' }),
scene
);
expect(next).toHaveBeenCalled();
controller.dispose();
const T = DataStreamTreeTokenType;
const table = `${T.TABLE_START}${T.TABLE_ROW_START}${T.TABLE_CELL_START}${T.CUSTOM_BLOCK}${T.PARAGRAPH}${T.SECTION_BREAK}${T.TABLE_CELL_END}${T.TABLE_ROW_END}${T.TABLE_END}`;
const dataStream = `${T.CUSTOM_BLOCK}${T.PARAGRAPH}${table}${T.PARAGRAPH}${T.CUSTOM_BLOCK}${T.PARAGRAPH}${T.SECTION_BREAK}`;
const drawings: Record<string, IDocDrawing> = Object.fromEntries(imageIds.map((drawingId) => [drawingId, {
unitId,
subUnitId: unitId,
drawingId,
drawingType: DrawingTypeEnum.DRAWING_IMAGE,
imageSourceType: ImageSourceType.BASE64,
source: drawingId,
transform: { left: 10, top: 10, width: 16, height: 16, angle: 0 },
docTransform: {
size: { width: 16, height: 16 },
positionH: { relativeFrom: ObjectRelativeFromH.PAGE, posOffset: 0 },
positionV: { relativeFrom: ObjectRelativeFromV.PARAGRAPH, posOffset: 0 },
angle: 0,
},
layoutType: PositionedObjectLayoutType.INLINE,
}]));
const segmentBody = (blockId: string) => ({
dataStream: `${T.CUSTOM_BLOCK}${T.PARAGRAPH}${T.SECTION_BREAK}`,
paragraphs: [{ startIndex: 1, paragraphId: `${blockId}-p` }],
sectionBreaks: [{ startIndex: 2, sectionId: `${blockId}-s` }],
customBlocks: [{ startIndex: 0, blockId }],
});
const snapshot: IDocumentData = {
id: unitId,
body: {
dataStream,
paragraphs: [...dataStream.matchAll(/\r/g)].map((match, index) => ({
startIndex: match.index!,
paragraphId: `p-${index}`,
paragraphStyle: { pageBreakBefore: index === 3 ? BooleanNumber.TRUE : BooleanNumber.FALSE },
})),
sectionBreaks: [...dataStream.matchAll(/\n/g)].map((match, index) => ({ startIndex: match.index!, sectionId: `s-${index}` })),
customBlocks: [
{ startIndex: 0, blockId: 'body-first' },
{ startIndex: 5, blockId: 'cell-image' },
{ startIndex: dataStream.length - 3, blockId: 'body-second' },
],
tables: [{ startIndex: 2, endIndex: 2 + table.length, tableId: 'table' }],
},
headers: { header: { headerId: 'header', body: segmentBody('header-image') } },
footers: { footer: { footerId: 'footer', body: segmentBody('footer-image') } },
drawings,
drawingsOrder: imageIds,
tableSource: {
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: 160 } },
tableRows: [{
tableCells: [{}],
trHeight: { val: { v: 25 }, hRule: TableRowHeightRule.AT_LEAST },
}],
tableColumns: [{ size: { type: TableSizeType.SPECIFIED, width: { v: 160 } } }],
},
},
documentStyle: {
documentFlavor: DocumentFlavor.TRADITIONAL,
autoHyphenation: BooleanNumber.FALSE,
pageSize: { width: 240, height: 400 },
marginTop: 50,
marginBottom: 50,
marginLeft: 20,
marginRight: 20,
marginHeader: 10,
marginFooter: 10,
defaultHeaderId: 'header',
defaultFooterId: 'footer',
},
};
const model = univer.createUnit<IDocumentData, DocumentDataModel>(UniverInstanceType.UNIVER_DOC, snapshot);
skeleton = DocumentSkeleton.create(new DocumentViewModel(model), injector.get(LocaleService));
skeleton.calculate();
expect(skeleton.getSkeletonData()!.pages).toHaveLength(2);
engine = new Engine('print-engine', { elementWidth: 240, elementHeight: 400 });
scene = new Scene('print-scene', engine);
documents = new Documents('print-document', skeleton);
const drawingManager = injector.get(IDrawingManagerService);
drawingManager.registerDrawingData(unitId, { [unitId]: { data: drawings, order: imageIds } });
drawingManager.setDrawingEditable(false);
printService = injector.get(DocPrintInterceptorService);
injector.get(DocDrawingPrintingController);
});
it('mounts chart and dom float components for printing and registers cleanup', () => {
const { controller, interceptors, points } = createController();
afterEach(() => {
scene?.dispose();
engine?.dispose();
skeleton?.dispose();
univer?.dispose();
vi.restoreAllMocks();
vi.clearAllMocks();
});
function collect(pageIndex?: number): void {
printService.interceptor.fetchThroughInterceptors(
printService.interceptor.getInterceptPoints().PRINTING_COMPONENT_COLLECT
)(undefined, { unitId, scene, engine, skeleton, documents, pageIndex, root: document.createElement('div') });
}
function renderedImageIds(): string[] {
return imageIds.filter((drawingId) => scene.getObject(getDrawingShapeKeyByDrawingSearch({ unitId, subUnitId: unitId, drawingId })) != null);
}
it('prints table-cell images with their host page and omits the other body page', () => {
const page = skeleton.getSkeletonData()!.pages[0];
expect(page.skeTables.get('table')!.rows[0].cells[0].skeDrawings.has('cell-image')).toBe(true);
collect(0);
expect(renderedImageIds()).toEqual(['body-first', 'cell-image', 'header-image', 'footer-image']);
});
it('prints repeated header and footer images on the second page', () => {
const data = skeleton.getSkeletonData()!;
expect(data.skeHeaders.get(data.pages[1].headerId)!.get(data.pages[1].pageWidth)!.skeDrawings.has('header-image')).toBe(true);
expect(data.skeFooters.get(data.pages[1].footerId)!.get(data.pages[1].pageWidth)!.skeDrawings.has('footer-image')).toBe(true);
collect(1);
expect(renderedImageIds()).toEqual(['body-second', 'header-image', 'footer-image']);
});
it('keeps unfiltered drawing collection when there is no traditional page index', () => {
collect();
expect(renderedImageIds()).toEqual(imageIds);
});
it('does not render another page when the requested print page is missing', () => {
collect(2);
expect(renderedImageIds()).toEqual([]);
});
it.each([
[0, ['body-first', 'cell-image', 'header-image', 'footer-image']],
[1, ['body-second', 'header-image', 'footer-image']],
] as const)('collects DOM drawings only from page %i including its tables and repeated segments', (pageIndex, expectedIds) => {
const injector = univer.__getInjector();
const manager = injector.get(IDrawingManagerService);
const drawings = Object.fromEntries(imageIds.map((drawingId) => [drawingId, {
unitId,
subUnitId: unitId,
drawingId,
drawingType: DrawingTypeEnum.DRAWING_DOM,
componentKey: 'custom-dom',
}]));
manager.setDrawingData(unitId, unitId, drawings);
injector.get(ComponentManager).register('custom-dom', () => null);
const disposables = new DisposableCollection();
printService.interceptor.fetchThroughInterceptors(
printService.interceptor.getInterceptPoints().PRINTING_DOM_COLLECT
)(disposables, {
unitId,
scene,
engine,
skeleton,
pageIndex,
root: document.createElement('div'),
offset: { x: 0, y: 0 },
bound: { top: 0, left: 0, right: 240, bottom: 400 },
});
const props = vi.mocked(mountDocPrintingFloatDom).mock.calls[0][0];
expect(props.floatDomInfos.map((drawing) => drawing.drawingId)).toEqual(expectedIds);
disposables.dispose();
});
it('mounts chart and DOM print components and disposes their DOM mount', () => {
const injector = univer.__getInjector();
const manager = injector.get(IDrawingManagerService);
const domDrawing = { unitId, subUnitId: unitId, drawingId: 'dom', drawingType: DrawingTypeEnum.DRAWING_DOM, componentKey: 'custom-dom' };
manager.setDrawingData(unitId, unitId, {
chart: { unitId, subUnitId: unitId, drawingId: 'chart', drawingType: DrawingTypeEnum.DRAWING_CHART },
dom: domDrawing,
});
manager.setDrawingOrder(unitId, unitId, ['chart', 'dom']);
const chartComponent = () => null;
const domComponent = () => null;
injector.get(ComponentManager).register(DOC_DRAWING_PRINTING_COMPONENT_KEY, chartComponent);
injector.get(ComponentManager).register('custom-dom-print', domComponent);
printService.registerPrintComponent('custom-dom', 'custom-dom-print');
const unmount = vi.fn();
vi.mocked(mountDocPrintingFloatDom).mockReturnValueOnce(unmount);
const add = vi.fn((dispose: () => void) => dispose());
const next = vi.fn((value) => value);
const disposableCollection = { add };
const result = interceptors.get(points.PRINTING_DOM_COLLECT).handler(disposableCollection, {
unitId: 'unit-1',
scene: 'scene',
skeleton: 'skeleton',
offset: 'offset',
bound: 'bound',
root: 'root',
}, next);
const disposables = new DisposableCollection();
const root = document.createElement('div');
const result = printService.interceptor.fetchThroughInterceptors(
printService.interceptor.getInterceptPoints().PRINTING_DOM_COLLECT
)(disposables, { unitId, scene, engine, root, skeleton, offset: { x: 0, y: 0 }, bound: { top: 0, left: 0, right: 240, bottom: 400 } });
expect(mountDocPrintingFloatDom).toHaveBeenCalledWith(
expect.objectContaining({
unitId: 'unit-1',
floatDomInfos: [
expect.objectContaining({ drawingId: 'chart-1', componentKey: 'ChartPrintingComponent' }),
expect.objectContaining({ drawingId: 'dom-1', componentKey: 'custom-dom-print-Component' }),
expect.objectContaining({ drawingId: 'chart', componentKey: chartComponent }),
expect.objectContaining({ drawingId: 'dom', componentKey: domComponent }),
],
}),
'root',
expect.any(Object)
root,
injector
);
expect(add).toHaveBeenCalled();
expect(unmount).toHaveBeenCalled();
expect(result).toBe(disposableCollection);
controller.dispose();
expect(result).toBe(disposables);
disposables.dispose();
expect(unmount).toHaveBeenCalledOnce();
});
});
@@ -494,6 +494,28 @@ describe('DocDrawingTransformerController business methods', () => {
expect(controller._getPageContentSize(drawing())).toEqual({ width: 620, height: 840 });
controller._renderManagerService.getRenderUnitById.mockReturnValue({
with: vi.fn(() => ({
getSkeleton: () => ({
getSkeletonData: () => ({
pages: [
{
isLayoutPlaceholder: true,
pageWidth: 720,
pageHeight: 960,
marginLeft: 40,
marginRight: 60,
marginTop: 50,
marginBottom: 70,
skeDrawings: new Map([['drawing-1', {}]]),
},
],
}),
}),
})),
});
expect(controller._getPageContentSize(drawing())).toEqual({ width: 500, height: 500 });
controller._renderManagerService.getRenderUnitById.mockReturnValue(null);
expect(controller._getPageContentSize(drawing())).toEqual({ width: 500, height: 500 });
});
@@ -14,21 +14,23 @@
* limitations under the License.
*/
/* eslint-disable ts/no-explicit-any */
import type { DocumentDataModel, ICommandInfo, IDrawingSearch, JSONXActions, Nullable } from '@univerjs/core';
import type { IRichTextEditingMutationParams } from '@univerjs/docs';
import type { IDocDrawing, IUpdateDocDrawingWrappingStyleParams, IUpdateDrawingDocTransformCommandParams } from '@univerjs/docs-drawing';
import type {
IDocDrawing,
IUpdateDocDrawingWrappingStyleParams,
IUpdateDrawingDocTransformCommandParams,
} from '@univerjs/docs-drawing';
import type { IDrawingJsonUndo1, IDrawingMapItemData, IDrawingOrderMapParam } from '@univerjs/drawing';
import type { IDocumentSkeletonDrawing, IDocumentSkeletonHeaderFooter, IDocumentSkeletonPage } from '@univerjs/engine-render';
import type { IDrawingAnchorInPage } from '../utils/drawing-anchor-position';
import {
BooleanNumber,
Disposable,
ICommandService,
Inject,
IUniverInstanceService,
JSONX,
ObjectRelativeFromH,
ObjectRelativeFromV,
PositionedObjectLayoutType,
RedoCommand,
UndoCommand,
UniverInstanceType,
@@ -44,6 +46,7 @@ import {
import { IDrawingManagerService } from '@univerjs/drawing';
import { DocumentEditArea, IRenderManagerService } from '@univerjs/engine-render';
import { DocRefreshDrawingsService } from '../services/doc-refresh-drawings.service';
import { findDrawingAnchorInPage, resolveDrawingAnchorOffsets } from '../utils/drawing-anchor-position';
interface IAddOrRemoveDrawing {
type: 'add' | 'remove';
@@ -51,37 +54,6 @@ interface IAddOrRemoveDrawing {
drawing?: IDocDrawing;
}
interface IDrawingAnchorInPage {
skeDrawing: IDocumentSkeletonDrawing;
pageMarginTop: number;
pageMarginLeft: number;
}
function findDrawingAnchorInPage(
page: IDocumentSkeletonPage | IDocumentSkeletonHeaderFooter,
drawingId: string,
pageMarginTop: number,
pageMarginLeft: number
): IDrawingAnchorInPage | null {
const skeDrawing = page.skeDrawings.get(drawingId);
if (skeDrawing) {
return { skeDrawing, pageMarginTop, pageMarginLeft };
}
for (const table of page.skeTables.values()) {
for (const row of table.rows) {
for (const cell of row.cells) {
const cellAnchor = findDrawingAnchorInPage(cell, drawingId, cell.marginTop, cell.marginLeft);
if (cellAnchor) {
return cellAnchor;
}
}
}
}
return null;
}
// Check whether drawings are added or deleted from the mutation and obtain the drawing ID.
// eslint-disable-next-line complexity
function getAddOrRemoveDrawings(actions: JSONXActions): Nullable<IAddOrRemoveDrawing[]> {
@@ -281,7 +253,13 @@ export class DocDrawingAddRemoveController extends Disposable {
return;
}
this._docRefreshDrawingsService.refreshDrawings(renderObject.with(DocSkeletonManagerService).getSkeleton());
// Transform mutations are already refreshed incrementally by
// DocDrawingTransformUpdateController from the nested rich-text
// mutation. Wrapping changes can move anchors and still need a
// conservative full refresh.
if (command.id === UpdateDocDrawingWrappingStyleCommand.id) {
this._docRefreshDrawingsService.refreshDrawings(renderObject.with(DocSkeletonManagerService).getSkeleton());
}
scene.getTransformerByCreate().refreshControls();
})
);
@@ -312,7 +290,6 @@ export class DocDrawingAddRemoveController extends Disposable {
);
}
// eslint-disable-next-line max-lines-per-function
private _preserveWrappingStylePosition(params: IUpdateDocDrawingWrappingStyleParams): void {
if (params.wrappingStyle === TextWrappingStyle.INLINE) {
return;
@@ -372,25 +349,13 @@ export class DocDrawingAddRemoveController extends Disposable {
return drawing;
}
const { skeDrawing, pageMarginTop, pageMarginLeft } = drawingAnchor;
const oldPositionH = oldDrawing.docTransform.positionH;
const oldPositionV = oldDrawing.docTransform.positionV;
let posOffsetH = skeDrawing.aLeft;
let posOffsetV = skeDrawing.aTop;
if (oldPositionH.relativeFrom === ObjectRelativeFromH.MARGIN) {
posOffsetH -= pageMarginLeft;
} else if (oldPositionH.relativeFrom === ObjectRelativeFromH.COLUMN) {
posOffsetH -= skeDrawing.columnLeft;
}
if (oldPositionV.relativeFrom === ObjectRelativeFromV.PAGE) {
posOffsetV += pageMarginTop;
} else if (oldPositionV.relativeFrom === ObjectRelativeFromV.LINE) {
posOffsetV -= skeDrawing.lineTop;
} else if (oldPositionV.relativeFrom === ObjectRelativeFromV.PARAGRAPH) {
posOffsetV -= skeDrawing.blockAnchorTop;
}
const { horizontal: posOffsetH, vertical: posOffsetV } = resolveDrawingAnchorOffsets(
drawingAnchor,
oldPositionH,
oldPositionV
);
return {
...oldDrawing,
@@ -485,12 +450,36 @@ export class DocDrawingAddRemoveController extends Disposable {
const { drawings = {}, drawingsOrder = [] } = documentDataModel.getSnapshot();
const drawingData = drawings as IDrawingMapItemData<IDocDrawing>;
const renderOrder = getDocDrawingRenderOrder(drawingsOrder, drawings);
const previousDrawings = this._docDrawingService.getDrawingData(unitId, unitId);
const orderChanged = drawingsOrder !== this._docDrawingService.getDrawingOrder(unitId, unitId) ||
drawingIds.some((drawingId) => {
const previous = previousDrawings[drawingId];
const current = drawingData[drawingId];
const wasBehind = previous?.layoutType === PositionedObjectLayoutType.WRAP_NONE &&
previous.behindDoc === BooleanNumber.TRUE;
const isBehind = current?.layoutType === PositionedObjectLayoutType.WRAP_NONE &&
current.behindDoc === BooleanNumber.TRUE;
return wasBehind !== isBehind;
});
const renderedDrawings = { ...this._drawingManagerService.getDrawingData(unitId, unitId) };
for (const drawingId of drawingIds) {
const current = drawingData[drawingId];
if (current) {
// Layout mutates render transforms. Never share the persisted drawing object or
// replace unrelated drawings whose published positions are still authoritative.
renderedDrawings[drawingId] = { ...current };
} else {
delete renderedDrawings[drawingId];
}
}
this._docDrawingService.setDrawingData(unitId, unitId, drawingData);
this._drawingManagerService.setDrawingData(unitId, unitId, drawingData);
this._docDrawingService.setDrawingOrder(unitId, unitId, drawingsOrder);
this._drawingManagerService.setDrawingOrder(unitId, unitId, renderOrder);
this._drawingManagerService.setDrawingData(unitId, unitId, renderedDrawings);
if (orderChanged) {
this._docDrawingService.setDrawingOrder(unitId, unitId, drawingsOrder);
this._drawingManagerService.setDrawingOrder(unitId, unitId, getDocDrawingRenderOrder(drawingsOrder, drawings));
}
const objects = drawingIds
.filter((drawingId) => drawingData[drawingId] != null)
@@ -42,11 +42,15 @@ export class DocDrawingPrintingController extends Disposable {
this._docPrintInterceptorService.interceptor.getInterceptPoints().PRINTING_COMPONENT_COLLECT,
{
handler: (_param, pos, next) => {
const { unitId, scene } = pos;
const { unitId, scene, skeleton, pageIndex } = pos;
const unitData = this._drawingManagerService.getDrawingDataForUnit(unitId);
const subUnitData = unitData?.[unitId];
const visibleDrawingIds = this._docPrintInterceptorService.getPageDrawingIds(skeleton, pageIndex);
if (subUnitData) {
subUnitData.order.forEach((id) => {
if (visibleDrawingIds && !visibleDrawingIds.has(id)) {
return;
}
const drawing = subUnitData.data[id];
if (drawing.drawingType !== DrawingTypeEnum.DRAWING_CHART && drawing.drawingType !== DrawingTypeEnum.DRAWING_DOM) {
this._drawingRenderService.renderDrawing(drawing, scene);
@@ -67,11 +71,12 @@ export class DocDrawingPrintingController extends Disposable {
this._docPrintInterceptorService.interceptor.getInterceptPoints().PRINTING_DOM_COLLECT,
{
handler: (disposableCollection, pos, next) => {
const { unitId } = pos;
const { unitId, skeleton, pageIndex } = pos;
const unitData = this._drawingManagerService.getDrawingDataForUnit(unitId);
const subUnitData = unitData?.[unitId];
const visibleDrawingIds = this._docPrintInterceptorService.getPageDrawingIds(skeleton, pageIndex);
if (subUnitData) {
const floatDomInfos = subUnitData.order.map((id) => {
const floatDomInfos = subUnitData.order.filter((id) => !visibleDrawingIds || visibleDrawingIds.has(id)).map((id) => {
const drawing = subUnitData.data[id] as IDocFloatDom;
if (drawing.drawingType === DrawingTypeEnum.DRAWING_CHART) {
return {
@@ -979,6 +979,10 @@ export class DocDrawingTransformerController extends Disposable {
let page: Nullable<IDocumentSkeletonPage> = null;
for (const p of pages) {
if (p.isLayoutPlaceholder || p.isMaterializationPlaceholder) {
continue;
}
const { skeDrawings } = p;
if (skeDrawings.has(drawing.drawingId)) {
page = p;
@@ -26,6 +26,7 @@ import {
import { Liquid, setDocsTableRenderViewportProvider } from '@univerjs/engine-render';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { getDocsTableCellAnchorContext } from '../../doc-drawing-transformer-update.controller';
import { doesDocMutationAffectDrawingPresentation, getDocMutationAffectedDrawingIds } from '../doc-drawing-mutation';
import {
DocDrawingPublicationTracker,
DocDrawingTransformUpdateController,
@@ -37,6 +38,7 @@ import {
getDocsPageRelativeDrawingLeft,
getDocsPageRelativeDrawingTop,
getDocsTableCellDrawingOffset,
shouldRefreshDocDrawingTransform,
} from '../doc-drawing-transform-update.controller';
describe('DocDrawingTransformUpdateController', () => {
@@ -44,6 +46,123 @@ describe('DocDrawingTransformUpdateController', () => {
setDocsTableRenderViewportProvider(null);
});
it('does not eagerly refresh drawings for table metadata mutations', () => {
expect(doesDocMutationAffectDrawingPresentation([
'tableSource',
'table-1',
'tableColumns',
0,
'size',
'width',
'v',
{ r: 28, i: 32 },
] as never)).toBe(false);
expect(doesDocMutationAffectDrawingPresentation([
'drawings',
'drawing-1',
'docTransform',
{ r: {}, i: { angle: 1 } },
] as never)).toBe(true);
expect([...getDocMutationAffectedDrawingIds([
'drawings',
'drawing-1',
'docTransform',
{ r: {}, i: { angle: 1 } },
] as never)!]).toEqual(['drawing-1']);
expect(getDocMutationAffectedDrawingIds([
'drawingsOrder',
{ r: [], i: ['drawing-1'] },
] as never)).toBeNull();
});
it('filters unchanged drawing transform notifications but preserves viewport refreshes', () => {
const current = {
unitId: 'doc-1',
subUnitId: 'doc-1',
drawingId: 'drawing-1',
drawingType: 0,
behindText: false,
hidden: false,
isMultiTransform: BooleanNumber.FALSE,
transform: { left: 10, top: 20, width: 30, height: 40 },
transforms: [],
};
expect(shouldRefreshDocDrawingTransform(current as never, { ...current } as never)).toBe(false);
expect(shouldRefreshDocDrawingTransform(current as never, {
...current,
transform: { ...current.transform, left: 11 },
} as never)).toBe(true);
expect(shouldRefreshDocDrawingTransform(current as never, {
...current,
customBlockRenderViewport: { contentWidth: 100 },
} as never)).toBe(true);
expect(shouldRefreshDocDrawingTransform(current as never, {
...current,
selectable: false,
} as never)).toBe(true);
});
it('publishes preview-only drawings as non-selectable and restores canonical drawings', () => {
const controller = Object.create(DocDrawingTransformUpdateController.prototype);
controller._context = {
unit: { getSnapshot: () => ({ drawings: {} }) },
};
controller._liquid = new Liquid();
const drawing = {
aLeft: 10,
aTop: 20,
width: 30,
height: 40,
angle: 0,
drawingId: 'drawing-1',
drawingOrigin: {
layoutType: PositionedObjectLayoutType.WRAP_SQUARE,
behindDoc: BooleanNumber.FALSE,
docTransform: { positionH: {}, positionV: {} },
},
};
const page = {
footerId: '',
headerId: '',
isLayoutPlaceholder: true,
marginBottom: 10,
marginLeft: 10,
marginRight: 10,
marginTop: 10,
pageHeight: 300,
pageWidth: 200,
skeColumnGroups: new Map(),
skeDrawings: new Map([['drawing-1', drawing]]),
skeTables: new Map(),
};
const previewDrawings = {} as Record<string, { selectable: boolean }>;
controller._collectPublishedPageDrawingPositions(
'doc-1',
page,
new Map(),
new Map(),
0,
0,
previewDrawings
);
expect(previewDrawings['drawing-1']).toMatchObject({ selectable: false });
page.isLayoutPlaceholder = false;
const canonicalDrawings = {} as Record<string, { selectable: boolean }>;
controller._collectPublishedPageDrawingPositions(
'doc-1',
page,
new Map(),
new Map(),
0,
0,
canonicalDrawings
);
expect(canonicalDrawings['drawing-1']).toMatchObject({ selectable: true });
});
it('refreshes only incremental publications that can change drawing transforms', () => {
const drawing = {};
const pages = [
@@ -59,21 +59,26 @@ function createController(options: {
resetProps: vi.fn(),
};
const shapeByDrawingId = new Map<string, any>();
const getShape = (drawingId: string) => {
if (!shapeByDrawingId.has(drawingId)) {
shapeByDrawingId.set(drawingId, {
drawingId,
evented: true,
oKey: `doc-1#-#doc-1#-#${drawingId}`,
setOpacity: vi.fn(),
transformerConfig: {},
});
}
return shapeByDrawingId.get(drawingId);
};
Object.keys(options.drawings ?? {}).forEach(getShape);
const scene = {
getTransformerByCreate: vi.fn(() => transformer),
fuzzyMathObjects: vi.fn((objectKey: string) => {
const drawingId = objectKey.split('#-#').at(-1) ?? objectKey;
if (!shapeByDrawingId.has(drawingId)) {
shapeByDrawingId.set(drawingId, {
drawingId,
evented: true,
oKey: drawingId,
setOpacity: vi.fn(),
transformerConfig: {},
});
}
return [shapeByDrawingId.get(drawingId)];
return [getShape(drawingId)];
}),
getAllObjects: vi.fn(() => [...shapeByDrawingId.values()]),
attachTransformerTo: vi.fn(),
detachTransformerFrom: vi.fn(),
getTransformer: vi.fn(() => transformer),
@@ -120,6 +125,7 @@ function createController(options: {
unitId: 'doc-1',
unit: {
getDrawings: vi.fn(() => snapshot.drawings),
getMutationRevision: vi.fn(() => 0),
getSnapshot: vi.fn(() => snapshot),
getBody: vi.fn(() => snapshot.body),
getSelfOrHeaderFooterModel: vi.fn((segmentId: string) => ({
@@ -499,7 +505,7 @@ describe('DocDrawingUpdateRenderController', () => {
setDocumentPermissionValue(permissionService, 'doc-1', 'doc-1', UnitAction.Edit, false);
expect(getShape('body-drawing').evented).toBe(false);
expect(transformer.clearControlByIds).toHaveBeenCalledWith(['body-drawing']);
expect(transformer.clearControlByIds).toHaveBeenCalledWith(['doc-1#-#doc-1#-#body-drawing']);
expect(getShape('body-drawing').transformerConfig).toMatchObject({
moveEnabled: false,
resizeEnabled: false,
@@ -574,6 +580,33 @@ describe('DocDrawingUpdateRenderController', () => {
expect(getShape('header-drawing').setOpacity).toHaveBeenLastCalledWith(1);
});
it('indexes scene drawings once and ignores duplicate focus notifications', () => {
const { getShape, onFocus$, scene } = createController({
drawings: {
'body-drawing': {
drawingId: 'body-drawing',
isMultiTransform: BooleanNumber.FALSE,
},
'header-drawing': {
drawingId: 'header-drawing',
isMultiTransform: BooleanNumber.TRUE,
},
},
isFocusing: true,
});
expect(scene.getAllObjects).toHaveBeenCalledTimes(1);
expect(scene.fuzzyMathObjects).not.toHaveBeenCalled();
getShape('body-drawing').setOpacity.mockClear();
getShape('header-drawing').setOpacity.mockClear();
onFocus$.next({});
expect(scene.getAllObjects).toHaveBeenCalledTimes(1);
expect(getShape('body-drawing').setOpacity).not.toHaveBeenCalled();
expect(getShape('header-drawing').setOpacity).not.toHaveBeenCalled();
});
it('keeps peer drawings interactive while a drawing owns focus', () => {
const {
focus$,
@@ -0,0 +1,67 @@
/**
* 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 { JSONXActions } from '@univerjs/core';
import { JSON1 } from '@univerjs/core';
export type DocMutationAffectedDrawingIds = ReadonlySet<string> | null;
/**
* Returns the drawing ids that can be refreshed independently. `null` means
* the mutation is drawing-related but needs a conservative full refresh.
*/
export function getDocMutationAffectedDrawingIds(actions: JSONXActions): DocMutationAffectedDrawingIds {
if (!Array.isArray(actions) || actions.length === 0) {
return null;
}
const drawingIds = new Set<string>();
let sawComponent = false;
let needsFullRefresh = false;
try {
const cursor = JSON1.type.readCursor(actions);
cursor.traverse(null, () => {
sawComponent = true;
const path = cursor.getPath();
const root = String(path[0]);
if (root === 'drawings') {
const drawingId = path[1];
if (typeof drawingId === 'string' && drawingId.length > 0) {
drawingIds.add(drawingId);
} else {
needsFullRefresh = true;
}
} else if (root === 'drawingsOrder' || path.includes('customBlocks')) {
needsFullRefresh = true;
}
});
} catch {
return null;
}
return !sawComponent || needsFullRefresh ? null : drawingIds;
}
/**
* Drawing editability and immediate overlay transforms only need refreshing
* when a rich-text mutation changes drawing metadata or custom-block records.
* Text, table, list, and document-style mutations are repainted from the next
* document-layout publication instead of scanning every drawing eagerly.
*/
export function doesDocMutationAffectDrawingPresentation(actions: JSONXActions): boolean {
const drawingIds = getDocMutationAffectedDrawingIds(actions);
return drawingIds == null || drawingIds.size > 0;
}
@@ -58,6 +58,7 @@ import {
} from '@univerjs/engine-render';
import { animationFrames, debounceTime, EMPTY, filter, map, merge, startWith, switchMap, take } from 'rxjs';
import { DocRefreshDrawingsService } from '../../services/doc-refresh-drawings.service';
import { getDocMutationAffectedDrawingIds } from './doc-drawing-mutation';
interface IDrawingParamsWithBehindText {
unitId: string;
@@ -65,6 +66,7 @@ interface IDrawingParamsWithBehindText {
drawingId: string;
behindText: boolean;
hidden?: boolean;
selectable: boolean;
transform: ITransformState;
transforms: ITransformState[];
customBlockRenderViewport?: Partial<Pick<IDocsCustomBlockRenderViewport, 'bleedLeft' | 'bleedWidth' | 'contentHeight' | 'contentWidth' | 'height' | 'pageContentWidth' | 'viewportHeight'>>;
@@ -80,10 +82,51 @@ interface IDrawingClipBounds {
height: number;
}
function mergePublishedDrawing(
drawings: Record<string, IDrawingParamsWithBehindText>,
next: IDrawingParamsWithBehindText
): void {
const current = drawings[next.drawingId];
if (current == null) {
drawings[next.drawingId] = next;
return;
}
if (next.selectable && !current.selectable) {
current.selectable = true;
current.transform = next.transform;
}
if (next.isMultiTransform === BooleanNumber.TRUE) {
current.transforms.push(next.transform);
}
}
interface IDrawingTransformStateWithClipBounds extends ITransformState {
clipBounds?: IDrawingClipBounds;
}
type DrawingRefreshParam = Partial<IDrawingParam & IDrawingParamsWithBehindText>;
function isSameTransformValue(left: unknown, right: unknown): boolean {
return left === right || JSON.stringify(left) === JSON.stringify(right);
}
export function shouldRefreshDocDrawingTransform(
current: DrawingRefreshParam | null | undefined,
next: DrawingRefreshParam
): boolean {
if (!current || next.customBlockRenderViewport != null) {
return true;
}
return !isSameTransformValue(current.transform, next.transform)
|| !isSameTransformValue(current.transforms, next.transforms)
|| current.isMultiTransform !== next.isMultiTransform
|| current.hidden !== next.hidden
|| current.behindText !== next.behindText
|| current.selectable !== next.selectable;
}
interface IDrawingPositionContext {
unitId: string;
page: IDocumentSkeletonPage | IDocumentSkeletonHeaderFooter;
@@ -92,6 +135,7 @@ interface IDrawingPositionContext {
pageOffsetLeft: number;
pageOffsetTop: number;
updateDrawingMap: Record<string, IDrawingParamsWithBehindText>;
selectable: boolean;
hostPage?: IDocumentSkeletonPage;
clipOffset?: { left: number; top: number };
}
@@ -543,12 +587,19 @@ export class DocDrawingTransformUpdateController extends Disposable implements I
return;
}
const drawingIds = command.id === RichTextEditingMutation.id
? getDocMutationAffectedDrawingIds(params.actions)
: null;
if (drawingIds?.size === 0) {
return;
}
if (command.id === RichTextEditingMutation.id && options?.fromChangeset) {
this._scheduleChangesetDrawingRefresh();
return;
}
this._refreshCurrentDrawing();
this._refreshCurrentDrawing(drawingIds ?? undefined);
}
})
);
@@ -572,7 +623,7 @@ export class DocDrawingTransformUpdateController extends Disposable implements I
});
}
private _refreshCurrentDrawing(): void {
private _refreshCurrentDrawing(drawingIds?: ReadonlySet<string>): void {
const skeleton = this._docSkeletonManagerService.getSkeleton();
if (skeleton == null) {
return;
@@ -585,7 +636,7 @@ export class DocDrawingTransformUpdateController extends Disposable implements I
return;
}
this._refreshDrawing(skeleton);
this._refreshDrawing(skeleton, drawingIds);
}
private _initTransformRefresh() {
@@ -609,7 +660,7 @@ export class DocDrawingTransformUpdateController extends Disposable implements I
);
}
private _refreshDrawing(skeleton: DocumentSkeleton) {
private _refreshDrawing(skeleton: DocumentSkeleton, drawingIds?: ReadonlySet<string>) {
const skeletonData = skeleton?.getSkeletonData();
const { mainComponent, unitId } = this._context;
const documentComponent = mainComponent as Documents;
@@ -644,23 +695,39 @@ export class DocDrawingTransformUpdateController extends Disposable implements I
this._liquid.translatePage(page, pageLayoutType, pageMarginLeft, pageMarginTop);
}
const updateDrawings = Object.values(updateDrawingMap);
const updateDrawings = Object.values(updateDrawingMap)
.filter((drawing) => drawingIds == null || drawingIds.has(drawing.drawingId));
for (const drawing of updateDrawings) {
drawing.hidden = false;
}
const staleNonMultiDrawings = this._getStaleNonMultiDrawings(unitId, updateDrawingMap);
const staleNonMultiDrawings = this._getStaleNonMultiDrawings(unitId, updateDrawingMap, drawingIds);
const nonMultiDrawings = updateDrawings
.filter((drawing) => !drawing.isMultiTransform)
.concat(staleNonMultiDrawings);
const multiDrawings = updateDrawings.filter((drawing) => drawing.isMultiTransform);
.concat(staleNonMultiDrawings)
.filter((drawing) => shouldRefreshDocDrawingTransform(
this._drawingManagerService.getDrawingByParam({
unitId,
subUnitId: unitId,
drawingId: drawing.drawingId,
}) as DrawingRefreshParam | undefined,
drawing as DrawingRefreshParam
));
if (nonMultiDrawings.length > 0) {
this._drawingManagerService.refreshTransform(nonMultiDrawings as unknown as IDrawingParam[]);
}
// if multiDrawings length is 0, also need to remove current multi drawings.
this._handleMultiDrawingsTransform(multiDrawings as unknown as IDrawingParam[]);
const targetedMultiDrawing = drawingIds != null && [...drawingIds].some((drawingId) =>
updateDrawingMap[drawingId]?.isMultiTransform === BooleanNumber.TRUE ||
this._drawingManagerService.getDrawingByParam({ unitId, subUnitId: unitId, drawingId })?.isMultiTransform === BooleanNumber.TRUE);
if (drawingIds == null || targetedMultiDrawing) {
// Multi-transform drawings are recreated as a group so repeated
// header/footer occurrences stay consistent.
const allMultiDrawings = Object.values(updateDrawingMap)
.filter((drawing) => drawing.isMultiTransform);
this._handleMultiDrawingsTransform(allMultiDrawings as unknown as IDrawingParam[]);
}
}
private _collectPublishedPageDrawingPositions(
@@ -672,6 +739,7 @@ export class DocDrawingTransformUpdateController extends Disposable implements I
docsTop: number,
updateDrawingMap: Record<string, IDrawingParamsWithBehindText>
): void {
const selectable = !page.isLayoutPlaceholder && !page.isMaterializationPlaceholder;
const { headerId, footerId, pageWidth } = page;
const headerPage = headerId ? skeHeaders.get(headerId)?.get(pageWidth) : undefined;
if (headerPage != null) {
@@ -683,7 +751,8 @@ export class DocDrawingTransformUpdateController extends Disposable implements I
updateDrawingMap,
headerPage.marginTop,
page.marginLeft,
page
page,
selectable
);
}
@@ -698,7 +767,8 @@ export class DocDrawingTransformUpdateController extends Disposable implements I
updateDrawingMap,
footerTop,
page.marginLeft,
page
page,
selectable
);
}
@@ -709,7 +779,9 @@ export class DocDrawingTransformUpdateController extends Disposable implements I
docsTop,
updateDrawingMap,
page.marginTop,
page.marginLeft
page.marginLeft,
undefined,
selectable
);
}
@@ -721,7 +793,8 @@ export class DocDrawingTransformUpdateController extends Disposable implements I
updateDrawingMap: Record<string, IDrawingParamsWithBehindText>,
marginTop: number,
marginLeft: number,
hostPage?: IDocumentSkeletonPage
hostPage: IDocumentSkeletonPage | undefined,
selectable: boolean
): void {
this._calculateDrawingPosition(
unitId,
@@ -731,7 +804,8 @@ export class DocDrawingTransformUpdateController extends Disposable implements I
updateDrawingMap,
marginTop,
marginLeft,
hostPage
hostPage,
selectable
);
this._calculateTableCellDrawingPositions(
unitId,
@@ -740,7 +814,8 @@ export class DocDrawingTransformUpdateController extends Disposable implements I
docsTop,
updateDrawingMap,
marginTop,
marginLeft
marginLeft,
selectable
);
this._calculateColumnGroupDrawingPositions(
unitId,
@@ -749,18 +824,21 @@ export class DocDrawingTransformUpdateController extends Disposable implements I
docsTop,
updateDrawingMap,
marginTop,
marginLeft
marginLeft,
selectable
);
}
private _getStaleNonMultiDrawings(
unitId: string,
updateDrawingMap: Record<string, IDrawingParamsWithBehindText>
updateDrawingMap: Record<string, IDrawingParamsWithBehindText>,
drawingIds?: ReadonlySet<string>
): IDrawingParamsWithBehindText[] {
const drawingData = this._drawingManagerService.getDrawingData(unitId, unitId) ?? {};
return Object.values(drawingData)
.filter((drawing) => drawing.isMultiTransform !== BooleanNumber.TRUE)
.filter((drawing) => drawingIds == null || drawingIds.has(drawing.drawingId))
.filter((drawing) => updateDrawingMap[drawing.drawingId] == null)
.map((drawing) => ({
unitId,
@@ -768,6 +846,7 @@ export class DocDrawingTransformUpdateController extends Disposable implements I
drawingId: drawing.drawingId,
behindText: false,
hidden: true,
selectable: true,
transform: drawing.transform,
transforms: drawing.transforms ?? [],
isMultiTransform: drawing.isMultiTransform ?? BooleanNumber.FALSE,
@@ -821,6 +900,7 @@ export class DocDrawingTransformUpdateController extends Disposable implements I
marginTop: number,
marginLeft: number,
hostPage?: IDocumentSkeletonPage,
selectable = true,
clipOffset?: { left: number; top: number }
) {
const { skeDrawings } = page;
@@ -840,6 +920,7 @@ export class DocDrawingTransformUpdateController extends Disposable implements I
pageOffsetTop,
updateDrawingMap,
hostPage,
selectable,
clipOffset,
};
skeDrawings.forEach((drawing) => this._collectDrawingPosition(drawing, drawingPositionContext));
@@ -910,21 +991,17 @@ export class DocDrawingTransformUpdateController extends Disposable implements I
flipY: runtimeDrawing.docTransform.flipY,
clipBounds,
};
const existingDrawing = context.updateDrawingMap[drawingId];
if (existingDrawing == null) {
context.updateDrawingMap[drawingId] = {
unitId: context.unitId,
subUnitId: context.unitId,
drawingId,
behindText,
transform,
transforms: [transform],
customBlockRenderViewport: drawing.customBlockRenderViewport,
isMultiTransform,
};
} else if (isMultiTransform === BooleanNumber.TRUE) {
existingDrawing.transforms.push(transform);
}
mergePublishedDrawing(context.updateDrawingMap, {
unitId: context.unitId,
subUnitId: context.unitId,
drawingId,
behindText,
selectable: context.selectable,
transform,
transforms: [transform],
customBlockRenderViewport: drawing.customBlockRenderViewport,
isMultiTransform,
});
}
private _calculateTableCellDrawingPositions(
@@ -934,7 +1011,8 @@ export class DocDrawingTransformUpdateController extends Disposable implements I
docsTop: number,
updateDrawingMap: Record<string, IDrawingParamsWithBehindText>,
baseMarginTop: number,
baseMarginLeft: number
baseMarginLeft: number,
selectable: boolean
) {
page.skeTables?.forEach((table) => {
table.rows.forEach((row) => {
@@ -960,6 +1038,7 @@ export class DocDrawingTransformUpdateController extends Disposable implements I
marginTop,
marginLeft,
undefined,
selectable,
{ left: marginLeft, top: marginTop }
);
this._calculateTableCellDrawingPositions(
@@ -969,7 +1048,8 @@ export class DocDrawingTransformUpdateController extends Disposable implements I
docsTop,
updateDrawingMap,
marginTop,
marginLeft
marginLeft,
selectable
);
this._calculateColumnGroupDrawingPositions(
unitId,
@@ -978,7 +1058,8 @@ export class DocDrawingTransformUpdateController extends Disposable implements I
docsTop,
updateDrawingMap,
marginTop,
marginLeft
marginLeft,
selectable
);
});
});
@@ -992,7 +1073,8 @@ export class DocDrawingTransformUpdateController extends Disposable implements I
docsTop: number,
updateDrawingMap: Record<string, IDrawingParamsWithBehindText>,
baseMarginTop: number,
baseMarginLeft: number
baseMarginLeft: number,
selectable: boolean
): void {
page.skeColumnGroups?.forEach((columnGroup) => {
columnGroup.columns.forEach((column) => {
@@ -1010,6 +1092,7 @@ export class DocDrawingTransformUpdateController extends Disposable implements I
marginTop,
marginLeft,
undefined,
selectable,
clipOffset
);
this._calculateTableCellDrawingPositions(
@@ -1019,7 +1102,8 @@ export class DocDrawingTransformUpdateController extends Disposable implements I
docsTop,
updateDrawingMap,
marginTop,
marginLeft
marginLeft,
selectable
);
this._calculateColumnGroupDrawingPositions(
unitId,
@@ -1028,7 +1112,8 @@ export class DocDrawingTransformUpdateController extends Disposable implements I
docsTop,
updateDrawingMap,
marginTop,
marginLeft
marginLeft,
selectable
);
});
});
@@ -66,6 +66,7 @@ import { debounceTime } from 'rxjs';
import { GroupDocDrawingCommand } from '../../commands/commands/group-doc-drawing.command';
import { UngroupDocDrawingCommand } from '../../commands/commands/ungroup-doc-drawing.command';
import { DocRefreshDrawingsService } from '../../services/doc-refresh-drawings.service';
import { getDocMutationAffectedDrawingIds } from './doc-drawing-mutation';
interface IImageInsertPosition {
left: number;
@@ -76,6 +77,8 @@ export class DocDrawingUpdateRenderController extends Disposable implements IRen
private readonly _editableTransformerConfigs = new WeakMap<BaseObject, ITransformerConfig>();
private readonly _editableEventedStates = new WeakMap<BaseObject, boolean>();
private _editStatusUpdateScheduled = false;
private _pendingEditStatusDrawingIds: Set<string> | null | undefined;
private _lastEditStatusContextKey: string | null = null;
constructor(
private readonly _context: IRenderContext<DocumentDataModel>,
@@ -101,15 +104,21 @@ export class DocDrawingUpdateRenderController extends Disposable implements IRen
this._focusDrawingListener();
this._transformDrawingListener();
this._editAreaChangeListener();
const scheduleEditStatusUpdate = (drawings: Array<{ unitId: string }>) => {
if (drawings.some((drawing) => drawing.unitId === this._context?.unitId)) {
this._scheduleDrawingsEditStatusUpdate();
const scheduleEditStatusUpdate = (drawings: Array<{ unitId: string; drawingId: string }>) => {
const relevantDrawings = drawings.filter((drawing) => drawing.unitId === this._context?.unitId);
if (relevantDrawings.length > 0) {
const drawingIds = relevantDrawings
.map((drawing) => drawing.drawingId)
.filter((drawingId): drawingId is string => typeof drawingId === 'string' && drawingId.length > 0);
this._scheduleDrawingsEditStatusUpdate(
drawingIds.length === relevantDrawings.length ? new Set(drawingIds) : undefined
);
}
};
this.disposeWithMe(this._drawingManagerService.add$.subscribe(scheduleEditStatusUpdate));
this.disposeWithMe(this._drawingManagerService.update$.subscribe(scheduleEditStatusUpdate));
this.disposeWithMe(this._permissionService.permissionPointUpdate$.subscribe(() => {
this._updateDrawingsEditStatus();
this._updateDrawingsEditStatus(undefined, true);
}));
}
@@ -468,7 +477,7 @@ export class DocDrawingUpdateRenderController extends Disposable implements IRen
}
// Update drawings edit status and opacity. You can not edit header footer images when you are editing body. and vice verse.
private _updateDrawingsEditStatus() {
private _updateDrawingsEditStatus(drawingIds?: ReadonlySet<string>, force = false) {
if (!this._context) return;
const { unit: docDataModel, scene, unitId } = this._context;
@@ -486,18 +495,31 @@ export class DocDrawingUpdateRenderController extends Disposable implements IRen
const isEditBody = viewModel.getEditArea() === DocumentEditArea.BODY;
const isDocInteractionFocusing = this._docSelectionRenderService.isFocusing
|| this._drawingManagerService.getFocusDrawings().some((drawing) => drawing.unitId === unitId);
const contextKey = `${viewModel.getEditArea()}:${isDocInteractionFocusing}`;
const effectiveDrawingIds = drawingIds != null && contextKey === this._lastEditStatusContextKey
? drawingIds
: undefined;
if (effectiveDrawingIds == null) {
if (!force && contextKey === this._lastEditStatusContextKey) {
return;
}
this._lastEditStatusContextKey = contextKey;
}
const readOnlyDrawingIds = new Set<string>();
const drawingShapesById = this._indexDrawingShapes(scene.getAllObjects(), unitId);
for (const key of Object.keys(drawings)) {
const drawing = drawings[key];
if (effectiveDrawingIds != null && !effectiveDrawingIds.has(drawing.drawingId)) {
continue;
}
const segmentId = getDocumentDrawingSegmentId(docDataModel, drawing.drawingId);
const editable = canEditDocumentTargets(this._permissionService, unitId, [
...getDocumentEntityParentPermissionObjectIds(docDataModel, segmentId, 'drawing', drawing.drawingId),
getDocumentEntityPermissionObjectId(segmentId, 'drawing', drawing.drawingId),
]);
this._recordReadOnlyDrawing(readOnlyDrawingIds, drawing.drawingId, editable);
const objectKey = getDrawingShapeKeyByDrawingSearch({ unitId, drawingId: drawing.drawingId, subUnitId: unitId });
const drawingShapes = scene.fuzzyMathObjects(objectKey, true);
const drawingShapes = drawingShapesById.get(drawing.drawingId) ?? [];
if (drawingShapes.length) {
for (const shape of drawingShapes) {
@@ -528,6 +550,24 @@ export class DocDrawingUpdateRenderController extends Disposable implements IRen
this._clearReadOnlyDrawingFocus(unitId, readOnlyDrawingIds);
}
private _indexDrawingShapes(shapes: BaseObject[], unitId: string): Map<string, BaseObject[]> {
const prefix = getDrawingShapeKeyByDrawingSearch({ unitId, subUnitId: unitId, drawingId: '' });
const result = new Map<string, BaseObject[]>();
for (const shape of shapes) {
if (!shape.oKey.startsWith(prefix)) {
continue;
}
const drawingId = shape.oKey.slice(prefix.length).split('#-#', 1)[0];
if (!drawingId) {
continue;
}
const drawingShapes = result.get(drawingId) ?? [];
drawingShapes.push(shape);
result.set(drawingId, drawingShapes);
}
return result;
}
private _recordReadOnlyDrawing(readOnlyDrawingIds: Set<string>, drawingId: string, editable: boolean): void {
if (!editable) {
readOnlyDrawingIds.add(drawingId);
@@ -583,7 +623,14 @@ export class DocDrawingUpdateRenderController extends Disposable implements IRen
};
}
private _scheduleDrawingsEditStatusUpdate(): void {
private _scheduleDrawingsEditStatusUpdate(drawingIds?: ReadonlySet<string>): void {
if (drawingIds == null) {
this._pendingEditStatusDrawingIds = null;
this._lastEditStatusContextKey = null;
} else if (this._pendingEditStatusDrawingIds !== null) {
this._pendingEditStatusDrawingIds ??= new Set<string>();
drawingIds.forEach((drawingId) => this._pendingEditStatusDrawingIds?.add(drawingId));
}
if (this._editStatusUpdateScheduled) {
return;
}
@@ -591,7 +638,9 @@ export class DocDrawingUpdateRenderController extends Disposable implements IRen
queueMicrotask(() => {
this._editStatusUpdateScheduled = false;
if (!this._disposed) {
this._updateDrawingsEditStatus();
const pendingDrawingIds = this._pendingEditStatusDrawingIds;
this._pendingEditStatusDrawingIds = undefined;
this._updateDrawingsEditStatus(pendingDrawingIds ?? undefined);
}
});
}
@@ -643,7 +692,7 @@ export class DocDrawingUpdateRenderController extends Disposable implements IRen
// To wait the image is rendered.
queueMicrotask(() => {
this._updateDrawingsEditStatus();
this._updateDrawingsEditStatus(undefined, true);
});
})
);
@@ -657,11 +706,13 @@ export class DocDrawingUpdateRenderController extends Disposable implements IRen
if (params?.unitId && params.unitId !== this._context.unitId) {
return;
}
const drawingIds = getDocMutationAffectedDrawingIds(params?.actions ?? null);
if (drawingIds?.size === 0) {
return;
}
// To wait the image is rendered.
queueMicrotask(() => {
this._updateDrawingsEditStatus();
});
this._scheduleDrawingsEditStatusUpdate(drawingIds ?? undefined);
})
);
}
@@ -0,0 +1,76 @@
/**
* 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 { IObjectPositionH, IObjectPositionV } from '@univerjs/core';
import type { IDocumentSkeletonDrawing, IDocumentSkeletonHeaderFooter, IDocumentSkeletonPage } from '@univerjs/engine-render';
import { ObjectRelativeFromH, ObjectRelativeFromV } from '@univerjs/core';
export interface IDrawingAnchorInPage {
skeDrawing: IDocumentSkeletonDrawing;
pageMarginTop: number;
pageMarginLeft: number;
}
export function findDrawingAnchorInPage(
page: IDocumentSkeletonPage | IDocumentSkeletonHeaderFooter,
drawingId: string,
pageMarginTop: number,
pageMarginLeft: number
): IDrawingAnchorInPage | null {
const skeDrawing = page.skeDrawings.get(drawingId);
if (skeDrawing) {
return { skeDrawing, pageMarginTop, pageMarginLeft };
}
for (const table of page.skeTables.values()) {
for (const row of table.rows) {
for (const cell of row.cells) {
const cellAnchor = findDrawingAnchorInPage(cell, drawingId, cell.marginTop, cell.marginLeft);
if (cellAnchor) {
return cellAnchor;
}
}
}
}
return null;
}
export function resolveDrawingAnchorOffsets(
anchor: IDrawingAnchorInPage,
positionH: IObjectPositionH,
positionV: IObjectPositionV
): { horizontal: number; vertical: number } {
const { skeDrawing, pageMarginTop, pageMarginLeft } = anchor;
let horizontal = skeDrawing.aLeft;
let vertical = skeDrawing.aTop;
if (positionH.relativeFrom === ObjectRelativeFromH.MARGIN) {
horizontal -= pageMarginLeft;
} else if (positionH.relativeFrom === ObjectRelativeFromH.COLUMN) {
horizontal -= skeDrawing.columnLeft;
}
if (positionV.relativeFrom === ObjectRelativeFromV.PAGE) {
vertical += pageMarginTop;
} else if (positionV.relativeFrom === ObjectRelativeFromV.LINE) {
vertical -= skeDrawing.lineTop;
} else if (positionV.relativeFrom === ObjectRelativeFromV.PARAGRAPH) {
vertical -= skeDrawing.blockAnchorTop;
}
return { horizontal, vertical };
}
@@ -136,3 +136,60 @@ describe('DocDrawingController', () => {
controller.dispose();
});
});
describe('DocDrawingController imported drawing identity regression', () => {
it('normalizes drawing unit identity when registering imported snapshot data', () => {
const registerDocDrawingData = vi.fn();
const registerRenderDrawingData = vi.fn();
const snapshot = {
drawings: {
shape1: {
unitId: 'doc-1',
subUnitId: 'stale-import-unit',
drawingId: 'shape1',
drawingType: 1,
},
},
drawingsOrder: ['shape1'],
};
const documentDataModel = {
getDrawings: () => snapshot.drawings,
getDrawingsOrder: () => snapshot.drawingsOrder,
};
const resourceManagerService = {
registerPluginResource: vi.fn(() => ({ dispose: vi.fn() })),
};
const controller = new DocDrawingController(
{ registerDrawingData: registerDocDrawingData } as never,
{ registerDrawingData: registerRenderDrawingData } as never,
resourceManagerService as never,
{
getUnit: vi.fn((_unitId: string, _type?: UniverInstanceType) => documentDataModel),
} as never,
{ registerCommand: vi.fn(() => ({ dispose: vi.fn() })) } as never
);
expect(controller.loadDrawingDataForUnit('doc-1')).toBe(true);
expect(registerDocDrawingData).toHaveBeenCalledWith('doc-1', expect.objectContaining({
'doc-1': expect.objectContaining({
data: {
shape1: expect.objectContaining({
unitId: 'doc-1',
subUnitId: 'doc-1',
}),
},
}),
}));
expect(registerRenderDrawingData).toHaveBeenCalledWith('doc-1', expect.objectContaining({
'doc-1': expect.objectContaining({
data: {
shape1: expect.objectContaining({
unitId: 'doc-1',
subUnitId: 'doc-1',
}),
},
}),
}));
controller.dispose();
});
});
@@ -149,16 +149,23 @@ export class DocDrawingController extends Disposable {
// TODO@wzhudev: should move to docs-drawing.
const normalizedDrawingData: IDrawingMapItemData<IDocDrawing> = {};
for (const [drawingId, drawing] of Object.entries(drawingDataModels)) {
normalizedDrawingData[drawingId] = drawing.unitId === unitId && drawing.subUnitId === subUnitId
? drawing
: { ...drawing, unitId, subUnitId };
}
const subDrawings = {
[subUnitId]: {
unitId,
subUnitId,
data: drawingDataModels as IDrawingMapItemData<IDocDrawing>,
data: normalizedDrawingData,
order: drawingOrderModel,
},
};
const renderDrawingData: IDrawingMapItemData<IDocDrawing> = {};
for (const [drawingId, drawing] of Object.entries(drawingDataModels)) {
for (const [drawingId, drawing] of Object.entries(normalizedDrawingData)) {
renderDrawingData[drawingId] = { ...drawing, hidden: true };
}
const renderSubDrawings = {
@@ -0,0 +1,157 @@
/**
* 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 { ISetTextSelectionsOperationParams } from '@univerjs/docs';
import type { RenderUnit } from '@univerjs/engine-render';
import { BooleanNumber, CustomRangeType, DocumentFlavor, ICommandService, Univer, UniverInstanceType } from '@univerjs/core';
import { DocLayoutExecutorService, DocSelectionManagerService, DocSkeletonManagerService, SetTextSelectionsOperation } from '@univerjs/docs';
import { DocCanvasPopManagerService } from '@univerjs/docs-ui';
import { CanvasColorService, Documents, ICanvasColorService, IRenderManagerService, RenderManagerService } from '@univerjs/engine-render';
import { CanvasPopupService, ICanvasPopupService } from '@univerjs/ui';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { DocHyperLinkPopupService } from '../../services/hyper-link-popup.service';
import { DocHyperLinkSelectionController } from '../doc-hyper-link-selection.controller';
describe.each([1, 3])('DocHyperLinkSelectionController with a %i-character link', (length) => {
let univer: Univer;
let commands: ICommandService;
let popup: DocHyperLinkPopupService;
const startIndex = 1;
const endIndex = startIndex + length - 1;
beforeEach(() => {
// Only Canvas is a platform stub; commands, selection, document data
// and popup lifecycle use their real services.
const context = 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(context as never);
univer = new Univer();
const injector = univer.__getInjector();
injector.add([IRenderManagerService, { useClass: RenderManagerService }]);
injector.add([ICanvasColorService, { useClass: CanvasColorService }]);
injector.add([ICanvasPopupService, { useClass: CanvasPopupService }]);
injector.add([DocLayoutExecutorService]);
injector.add([DocSelectionManagerService]);
injector.add([DocCanvasPopManagerService]);
injector.add([DocHyperLinkPopupService]);
injector.add([DocHyperLinkSelectionController]);
const model = univer.createUnit<IDocumentData, DocumentDataModel>(UniverInstanceType.UNIVER_DOC, {
id: 'doc-unit',
body: {
dataStream: 'A目CDZ\r\n',
paragraphs: [{ startIndex: 5, paragraphId: 'paragraph-1' }],
sectionBreaks: [{ startIndex: 6, sectionId: 'body' }],
customRanges: [
{ rangeId: 'link-1', rangeType: CustomRangeType.HYPERLINK, startIndex, endIndex },
{ rangeId: 'link-2', rangeType: CustomRangeType.HYPERLINK, startIndex: endIndex + 1, endIndex: endIndex + 1 },
],
},
documentStyle: {
documentFlavor: DocumentFlavor.TRADITIONAL,
autoHyphenation: BooleanNumber.FALSE,
pageSize: { width: 300, height: 400 },
marginTop: 20,
marginBottom: 20,
marginLeft: 20,
marginRight: 20,
},
});
const render = injector.get(IRenderManagerService).createRender(model.getUnitId()) as RenderUnit;
render.deactivate();
render.engine.resizeBySize(300, 400);
vi.spyOn(render.engine.getCanvasElement()!, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 0, 300, 400));
render.addRenderDependencies([[DocSkeletonManagerService]]);
const documents = new Documents('link-selection-document', render.with(DocSkeletonManagerService).getSkeleton());
render.mainComponent = documents;
render.scene.addObject(documents);
commands = injector.get(ICommandService);
commands.registerCommand(SetTextSelectionsOperation);
popup = injector.get(DocHyperLinkPopupService);
injector.get(DocHyperLinkSelectionController);
});
afterEach(() => {
univer.dispose();
vi.restoreAllMocks();
});
async function select(startOffset: number, endOffset = startOffset): Promise<void> {
const params: ISetTextSelectionsOperationParams = {
unitId: 'doc-unit',
subUnitId: 'doc-unit',
segmentId: '',
isEditing: false,
style: { fill: '', stroke: '', strokeActive: '', strokeWidth: 0 },
ranges: [{ startOffset, endOffset, collapsed: startOffset === endOffset, segmentPage: 0 }],
};
expect(await commands.executeCommand(SetTextSelectionsOperation.id, params)).toBe(true);
}
it.each(['first', 'last', 'trailing'] as const)('keeps details visible at the %s caret boundary', async (boundary) => {
const offsets = { first: startIndex, last: endIndex, trailing: endIndex + 1 };
const info = { unitId: 'doc-unit', linkId: 'link-1', segmentId: '', segmentPage: 0, startIndex, endIndex };
// Pointer events open details before the asynchronous selection operation
// completes. Repeated clicks must not close that same popup.
for (let attempt = 0; attempt < 3; attempt++) {
popup.showInfoPopup(info);
await select(offsets[boundary]);
expect(popup.showing).toEqual(info);
}
});
it.each(['before', 'after'] as const)('closes details for a caret %s the link', async (boundary) => {
await select(startIndex);
expect(popup.showing?.linkId).toBe('link-1');
await select(boundary === 'before' ? startIndex - 1 : endIndex + 2);
expect(popup.showing).toBeNull();
});
it('keeps the clicked adjacent link instead of switching to the preceding link', async () => {
const info = {
unitId: 'doc-unit',
linkId: 'link-2',
segmentId: '',
segmentPage: 0,
startIndex: endIndex + 1,
endIndex: endIndex + 1,
};
popup.showInfoPopup(info);
await select(endIndex + 1);
expect(popup.showing).toEqual(info);
});
it('resolves a fresh caret at an adjacent link using the character at that offset', async () => {
await select(endIndex + 1);
expect(popup.showing?.linkId).toBe('link-2');
});
it('closes details for an expanded selection', async () => {
await select(startIndex);
expect(popup.showing?.linkId).toBe('link-1');
await select(startIndex, endIndex + 1);
expect(popup.showing).toBeNull();
});
});
@@ -42,9 +42,25 @@ export class DocHyperLinkSelectionController extends Disposable {
const primary = ranges[0];
if (primary?.collapsed && doc) {
const { startOffset, endOffset, segmentPage } = primary;
const customRange = doc.getSelfOrHeaderFooterModel(segmentId)?.getBody()?.customRanges?.find((value) => (
const customRanges = doc.getSelfOrHeaderFooterModel(segmentId)?.getBody()?.customRanges;
const showing = this._docHyperLinkService.showing;
// Pointer-up opens the clicked link before selection completes.
// Preserve that link at its trailing edge without claiming the
// leading edge of an adjacent link for a fresh caret selection.
const trailingLink = showing?.unitId === unitId &&
(showing.segmentId ?? '') === segmentId &&
showing.segmentPage === segmentPage &&
showing.endIndex === endOffset - 1
? customRanges?.find((value) => (
value.rangeId === showing.linkId &&
value.rangeType === CustomRangeType.HYPERLINK &&
value.startIndex <= startOffset &&
value.endIndex === endOffset - 1
))
: undefined;
const customRange = trailingLink ?? customRanges?.find((value) => (
value.rangeType === CustomRangeType.HYPERLINK &&
value.startIndex < startOffset &&
value.startIndex <= startOffset &&
value.endIndex > endOffset - 1
));
if (customRange) {
@@ -54,18 +54,22 @@ describe('DocHyperLinkEventRenderController', () => {
}
});
it('opens document hyperlinks only with Ctrl or Command click', () => {
it('shows hyperlink details on click and opens only with Ctrl or Command click', () => {
const hoverCustomRanges$ = new Subject<unknown[]>();
const clickCustomRanges$ = new Subject<unknown>();
const pointerDownCustomRanges$ = new Subject<unknown[]>();
const commandService = {
executeCommand: vi.fn(),
};
const hyperLinkPopupService = {
showing: false,
showInfoPopup: vi.fn(),
};
const controller = new DocHyperLinkEventRenderController(
{ unitId: 'doc-unit' } as never,
{ hoverCustomRanges$, clickCustomRanges$, pointerDownCustomRanges$ } as never,
commandService as never,
{ showing: false } as never,
hyperLinkPopupService as never,
{ getSkeleton: vi.fn() } as never,
{ getTextRanges: () => [] } as never
);
@@ -73,6 +77,8 @@ describe('DocHyperLinkEventRenderController', () => {
range: {
rangeId: 'link-1',
rangeType: CustomRangeType.HYPERLINK,
startIndex: 4,
endIndex: 10,
},
segmentId: 'header-1',
segmentPageIndex: 0,
@@ -81,6 +87,17 @@ describe('DocHyperLinkEventRenderController', () => {
clickCustomRanges$.next({ ...clickedRange, ctrlKey: false, metaKey: false });
expect(commandService.executeCommand).not.toHaveBeenCalled();
expect(hyperLinkPopupService.showInfoPopup).toHaveBeenCalledWith(
{
unitId: 'doc-unit',
linkId: 'link-1',
segmentId: 'header-1',
segmentPage: 0,
startIndex: 4,
endIndex: 10,
},
{ pinned: true }
);
clickCustomRanges$.next({ ...clickedRange, ctrlKey: true, metaKey: false });
expect(commandService.executeCommand).toHaveBeenLastCalledWith(ClickDocHyperLinkOperation.id, {
@@ -100,6 +117,81 @@ describe('DocHyperLinkEventRenderController', () => {
controller.dispose();
});
it('keeps click-pinned hyperlink details visible when the pointer leaves the link', () => {
const hoverCustomRanges$ = new Subject<unknown[]>();
const clickCustomRanges$ = new Subject<unknown>();
const pointerDownCustomRanges$ = new Subject<unknown[]>();
const commandService = {
executeCommand: vi.fn(),
};
const hyperLinkPopupService = {
showing: false as false | { linkId: string },
infoPopupPinned: false,
showInfoPopup: vi.fn(function (this: { showing: false | { linkId: string }; infoPopupPinned: boolean }, _info, options?: { pinned?: boolean }) {
this.showing = { linkId: 'link-1' };
this.infoPopupPinned = options?.pinned ?? false;
}),
};
const controller = new DocHyperLinkEventRenderController(
{ unitId: 'doc-unit' } as never,
{ hoverCustomRanges$, clickCustomRanges$, pointerDownCustomRanges$ } as never,
commandService as never,
hyperLinkPopupService as never,
{ getSkeleton: vi.fn() } as never,
{ getTextRanges: () => [] } as never
);
const clickedRange = {
range: {
rangeId: 'link-1',
rangeType: CustomRangeType.HYPERLINK,
startIndex: 4,
endIndex: 10,
},
segmentId: '',
segmentPageIndex: 0,
rects: [],
ctrlKey: false,
metaKey: false,
};
clickCustomRanges$.next(clickedRange);
hoverCustomRanges$.next([]);
expect(hyperLinkPopupService.infoPopupPinned).toBe(true);
expect(commandService.executeCommand).not.toHaveBeenCalled();
controller.dispose();
});
it('schedules hover-opened hyperlink details to close when the pointer leaves the link', () => {
const hoverCustomRanges$ = new Subject<unknown[]>();
const clickCustomRanges$ = new Subject<unknown>();
const pointerDownCustomRanges$ = new Subject<unknown[]>();
const commandService = {
executeCommand: vi.fn(),
};
const hyperLinkPopupService = {
showing: { linkId: 'link-1' },
infoPopupPinned: false,
scheduleHideInfoPopup: vi.fn(),
};
const controller = new DocHyperLinkEventRenderController(
{ unitId: 'doc-unit' } as never,
{ hoverCustomRanges$, clickCustomRanges$, pointerDownCustomRanges$ } as never,
commandService as never,
hyperLinkPopupService as never,
{ getSkeleton: vi.fn() } as never,
{ getTextRanges: () => [] } as never
);
hoverCustomRanges$.next([]);
expect(hyperLinkPopupService.scheduleHideInfoPopup).toHaveBeenCalledTimes(1);
expect(commandService.executeCommand).not.toHaveBeenCalled();
controller.dispose();
});
it('hides the hyperlink popup immediately when pointer down is outside a hyperlink', () => {
const hoverCustomRanges$ = new Subject<unknown[]>();
const clickCustomRanges$ = new Subject<unknown>();
@@ -0,0 +1,95 @@
/**
* 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 type { DocumentDataModel } from '@univerjs/core';
import type { IRenderContext } from '@univerjs/engine-render';
import {
createDocumentModelWithStyle,
CustomRangeType,
IUniverInstanceService,
Univer,
UniverInstanceType,
} from '@univerjs/core';
import {
DocInterceptorService,
DocLayoutExecutorService,
DocSelectionManagerService,
DocSkeletonManagerService,
} from '@univerjs/docs';
import { DocCanvasPopManagerService } from '@univerjs/docs-ui';
import { IRenderManagerService } from '@univerjs/engine-render';
import { NEVER } from 'rxjs';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { DocHyperLinkPopupService } from '../../../services/hyper-link-popup.service';
import { DocHyperLinkRenderController } from '../render.controller';
describe('DocHyperLinkRenderController', () => {
afterEach(() => vi.restoreAllMocks());
it('updates popup state without requiring a document layout controller or changing page geometry', () => {
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
font: '',
measureText: (text: string) => ({ width: text.length * 8, actualBoundingBoxAscent: 8, actualBoundingBoxDescent: 2 }),
} as never);
const univer = new Univer();
const injector = univer.__getInjector();
const model = createDocumentModelWithStyle('Link text\r', {});
model.getBody()!.customRanges = [{ startIndex: 0, endIndex: 3, rangeId: 'link', rangeType: CustomRangeType.HYPERLINK }];
injector.get(IUniverInstanceService).__addUnit(model);
const context = { unit: model, unitId: model.getUnitId(), type: UniverInstanceType.UNIVER_DOC } as IRenderContext<DocumentDataModel>;
injector.add([DocLayoutExecutorService]);
injector.add([DocSelectionManagerService]);
injector.add([DocSkeletonManagerService, { useFactory: () => injector.createInstance(DocSkeletonManagerService, context) }]);
injector.add([DocInterceptorService, { useFactory: () => injector.createInstance(DocInterceptorService, context) }]);
injector.add([IRenderManagerService, { useValue: { disposed$: NEVER } as unknown as IRenderManagerService }]);
// Canvas popup mounting is the unavailable platform boundary in this unit test.
const disposePopup = vi.fn();
injector.add([DocCanvasPopManagerService, { useValue: {
attachPopupToRange: () => ({ dispose: disposePopup, canDispose: () => true }),
} as unknown as DocCanvasPopManagerService }]);
injector.add([DocHyperLinkPopupService]);
injector.add([DocHyperLinkRenderController, { useFactory: () => injector.createInstance(DocHyperLinkRenderController, context) }]);
const controller = injector.get(DocHyperLinkRenderController);
const skeletonManager = injector.get(DocSkeletonManagerService);
const skeleton = skeletonManager.getSkeleton();
const pages = skeleton.getSkeletonData()!.pages;
const calculate = vi.spyOn(skeleton, 'calculate');
const startLayout = vi.spyOn(skeleton, 'startIncrementalLayout');
const popup = injector.get(DocHyperLinkPopupService);
const link = { unitId: model.getUnitId(), linkId: 'link', startIndex: 0, endIndex: 3 };
try {
for (let click = 0; click < 3; click++) {
popup.showInfoPopup(link);
expect(popup.showing).toMatchObject(link);
expect(skeletonManager.getViewModel().getCustomRange(0)?.active).toBe(true);
popup.hideInfoPopup();
expect(popup.showing).toBeNull();
expect(skeletonManager.getViewModel().getCustomRange(0)?.active).toBe(false);
}
expect(calculate).not.toHaveBeenCalled();
expect(startLayout).not.toHaveBeenCalled();
expect(skeleton.getSkeletonData()!.pages).toBe(pages);
expect(disposePopup).toHaveBeenCalled();
} finally {
controller.dispose();
skeleton.dispose();
univer.dispose();
}
});
});
@@ -47,10 +47,12 @@ export class DocHyperLinkEventRenderController extends Disposable implements IRe
}
private _hideInfoPopup() {
if (this._hyperLinkPopupService.infoPopupPinned) {
return;
}
if (this._hyperLinkPopupService.showing) {
this._commandService.executeCommand(
ToggleDocHyperLinkInfoPopupOperation.id
);
this._hyperLinkPopupService.scheduleHideInfoPopup();
}
}
@@ -99,7 +101,22 @@ export class DocHyperLinkEventRenderController extends Disposable implements IRe
this.disposeWithMe(
this._docEventManagerService.clickCustomRanges$.subscribe((range) => {
const link = range.range;
if (link.rangeType !== CustomRangeType.HYPERLINK || (!range.ctrlKey && !range.metaKey)) {
if (link.rangeType !== CustomRangeType.HYPERLINK) {
return;
}
if (!range.ctrlKey && !range.metaKey) {
this._hyperLinkPopupService.showInfoPopup(
{
unitId: this._context.unitId,
linkId: link.rangeId,
segmentId: range.segmentId,
segmentPage: range.segmentPageIndex,
startIndex: link.startIndex,
endIndex: link.endIndex,
},
{ pinned: true }
);
return;
}
@@ -18,21 +18,19 @@ import type { DocumentDataModel } from '@univerjs/core';
import type { IRenderContext, IRenderModule } from '@univerjs/engine-render';
import { Disposable, Inject } from '@univerjs/core';
import { DOC_INTERCEPTOR_POINT, DocInterceptorService } from '@univerjs/docs';
import { DocRenderController } from '@univerjs/docs-ui';
import { distinctUntilChanged, pairwise } from 'rxjs';
import { DocHyperLinkPopupService } from '../../services/hyper-link-popup.service';
export class DocHyperLinkRenderController extends Disposable implements IRenderModule {
constructor(
private readonly _context: IRenderContext<DocumentDataModel>,
_context: IRenderContext<DocumentDataModel>,
@Inject(DocInterceptorService) private readonly _docInterceptorService: DocInterceptorService,
@Inject(DocHyperLinkPopupService) private readonly _hyperLinkService: DocHyperLinkPopupService,
@Inject(DocRenderController) private readonly _docRenderController: DocRenderController
@Inject(DocHyperLinkPopupService) private readonly _hyperLinkService: DocHyperLinkPopupService
) {
super();
// Hyperlink underline/color no longer depend on popup activation. Opening
// or closing the popup must not schedule a document layout generation.
this._init();
this._initReRender();
}
private _init() {
@@ -60,23 +58,4 @@ export class DocHyperLinkRenderController extends Disposable implements IRenderM
},
});
}
private _initReRender() {
this.disposeWithMe(this._hyperLinkService.showingLink$
.pipe(
distinctUntilChanged((prev, aft) => prev?.linkId === aft?.linkId && prev?.unitId === aft?.unitId && prev?.startIndex === aft?.startIndex),
pairwise()
)
.subscribe(([preLink, link]) => {
if (link) {
if (link.unitId === this._context.unitId) {
this._docRenderController.reRender(link.unitId);
}
} else {
if (preLink && preLink.unitId === this._context.unitId) {
this._docRenderController.reRender(preLink.unitId);
}
}
}));
}
}
@@ -23,7 +23,7 @@ import { DocSelectionManagerService, setDocumentPermissionValue } from '@univerj
import { DocCanvasPopManagerService } from '@univerjs/docs-ui';
import { IRenderManagerService, RenderManagerService } from '@univerjs/engine-render';
import { UnitAction } from '@univerjs/protocol';
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import { DocHyperLinkPopupService } from '../hyper-link-popup.service';
class CapturingDocCanvasPopManagerService {
@@ -195,6 +195,65 @@ describe('DocHyperLinkPopupService', () => {
expect(disposed).toEqual([1]);
});
it('keeps a click-pinned popup pinned until another link replaces it or it closes', () => {
const { service } = createService();
const firstLink = {
unitId: 'doc-1',
linkId: 'link-1',
startIndex: 4,
endIndex: 8,
};
const secondLink = {
unitId: 'doc-1',
linkId: 'link-2',
startIndex: 0,
endIndex: 3,
};
service.showInfoPopup(firstLink, { pinned: true });
expect(service.infoPopupPinned).toBe(true);
service.showInfoPopup({ ...firstLink, segmentId: '' });
expect(service.infoPopupPinned).toBe(true);
expect(service.showing).toEqual(firstLink);
service.showInfoPopup(secondLink);
expect(service.infoPopupPinned).toBe(false);
service.hideInfoPopup();
expect(service.infoPopupPinned).toBe(false);
});
it('keeps a hovered popup visible while the pointer crosses to it and closes after the handoff delay', () => {
vi.useFakeTimers();
const { service, disposed } = createService();
const link = {
unitId: 'doc-1',
linkId: 'link-1',
startIndex: 4,
endIndex: 8,
};
try {
service.showInfoPopup(link);
service.scheduleHideInfoPopup();
vi.advanceTimersByTime(149);
expect(service.showing).toEqual(link);
service.cancelScheduledHideInfoPopup();
vi.advanceTimersByTime(1);
expect(service.showing).toEqual(link);
service.scheduleHideInfoPopup();
vi.advanceTimersByTime(150);
expect(service.showing).toBeNull();
expect(disposed).toEqual([1]);
} finally {
service.dispose();
vi.useRealTimers();
}
});
it('does not show link information when the target document is not loaded', () => {
const { service, attached } = createService();
@@ -33,6 +33,17 @@ export interface ILinkInfo {
type LinkPopupDisposable = ReturnType<DocCanvasPopManagerService['attachPopupToRange']>;
const INFO_POPUP_HIDE_DELAY = 150;
function isSameLinkInfo(current: ILinkInfo | null, next: ILinkInfo): boolean {
return current?.linkId === next.linkId &&
current.unitId === next.unitId &&
(current.segmentId ?? '') === (next.segmentId ?? '') &&
current.segmentPage === next.segmentPage &&
current.startIndex === next.startIndex &&
current.endIndex === next.endIndex;
}
export class DocHyperLinkPopupService extends Disposable {
private readonly _editingLink$ = new BehaviorSubject<ILinkInfo | null>(null);
private readonly _showingLink$ = new BehaviorSubject<ILinkInfo | null>(null);
@@ -42,6 +53,8 @@ export class DocHyperLinkPopupService extends Disposable {
private _editPopup: LinkPopupDisposable | null = null;
private _editPopupUnitId: string | null = null;
private _infoPopup: LinkPopupDisposable | null = null;
private _infoPopupPinned = false;
private _infoPopupHideTimer: ReturnType<typeof setTimeout> | null = null;
private _infoPopupSuppressed = false;
private _infoPopupSuppressionTimer: ReturnType<typeof setTimeout> | null = null;
@@ -55,6 +68,7 @@ export class DocHyperLinkPopupService extends Disposable {
super();
this.disposeWithMe(() => {
this.cancelScheduledHideInfoPopup();
if (this._infoPopupSuppressionTimer !== null) {
clearTimeout(this._infoPopupSuppressionTimer);
}
@@ -96,6 +110,10 @@ export class DocHyperLinkPopupService extends Disposable {
return this._showingLink$.value;
}
get infoPopupPinned() {
return this._infoPopupPinned;
}
showEditPopup(unitId: string, linkInfo: ILinkInfo | null): LinkPopupDisposable | null {
if (!this.canEditLink(unitId, linkInfo)) {
return null;
@@ -149,20 +167,18 @@ export class DocHyperLinkPopupService extends Disposable {
this._editPopupUnitId = null;
}
showInfoPopup(info: ILinkInfo): LinkPopupDisposable | null | undefined {
showInfoPopup(info: ILinkInfo, options?: { pinned?: boolean }): LinkPopupDisposable | null | undefined {
this.cancelScheduledHideInfoPopup();
if (this._infoPopupSuppressed) {
return;
}
const { linkId, unitId, segmentId, segmentPage, startIndex, endIndex } = info;
if (
this.showing?.linkId === linkId &&
this.showing?.unitId === unitId &&
this.showing?.segmentId === segmentId &&
this.showing?.segmentPage === segmentPage &&
this.showing?.startIndex === startIndex &&
this.showing?.endIndex === endIndex
) {
const isSameLink = isSameLinkInfo(this.showing, info);
if (isSameLink) {
if (options?.pinned) {
this._infoPopupPinned = true;
}
return;
}
@@ -174,6 +190,7 @@ export class DocHyperLinkPopupService extends Disposable {
if (!(doc instanceof DocumentDataModel)) {
return;
}
this._infoPopupPinned = options?.pinned ?? false;
this._showingLink$.next({ unitId, linkId, segmentId, segmentPage, startIndex, endIndex });
this._infoPopup = this._docCanvasPopupManagerService.attachPopupToRange(
@@ -199,11 +216,34 @@ export class DocHyperLinkPopupService extends Disposable {
}
hideInfoPopup() {
this.cancelScheduledHideInfoPopup();
this._infoPopupPinned = false;
this._showingLink$.next(null);
this._infoPopup?.dispose();
this._infoPopup = null;
}
scheduleHideInfoPopup() {
if (this._infoPopupPinned || !this.showing) {
return;
}
this.cancelScheduledHideInfoPopup();
this._infoPopupHideTimer = setTimeout(() => {
this._infoPopupHideTimer = null;
if (!this._infoPopupPinned) {
this.hideInfoPopup();
}
}, INFO_POPUP_HIDE_DELAY);
}
cancelScheduledHideInfoPopup() {
if (this._infoPopupHideTimer !== null) {
clearTimeout(this._infoPopupHideTimer);
this._infoPopupHideTimer = null;
}
}
hideInfoPopupOnPointerDown() {
this._infoPopupSuppressed = true;
if (this._infoPopupSuppressionTimer !== null) {
@@ -66,6 +66,12 @@ export const DocLinkPopup = () => {
univer-overflow-hidden univer-rounded-lg univer-bg-gray-0 univer-p-3 univer-shadow
dark:!univer-bg-gray-900
`, borderClassName)}
onPointerEnter={() => {
hyperLinkService.cancelScheduledHideInfoPopup();
}}
onPointerLeave={() => {
hyperLinkService.scheduleHideInfoPopup();
}}
onClick={() => {
hyperLinkService.hideInfoPopup();
}}
@@ -36,8 +36,7 @@ describe('DocThreadCommentRenderController', () => {
}),
};
const reRender = vi.fn();
const docRenderController = { reRender };
const makeDirty = vi.fn();
const activeCommentId$ = new BehaviorSubject<any>(undefined);
const hoveredCommentId$ = new BehaviorSubject<any>(undefined);
@@ -76,6 +75,7 @@ describe('DocThreadCommentRenderController', () => {
const context = {
unit,
unitId: 'doc-1',
mainComponent: { makeDirty },
scene: {
addObject: vi.fn(),
getObject: vi.fn(),
@@ -101,7 +101,6 @@ describe('DocThreadCommentRenderController', () => {
context as any,
docInterceptorService as any,
threadCommentPanelService as any,
docRenderController as any,
univerInstanceService as any,
threadCommentModel as any,
commandService as any,
@@ -109,7 +108,7 @@ describe('DocThreadCommentRenderController', () => {
themeService as any
);
expect(reRender).not.toHaveBeenCalled();
expect(makeDirty).not.toHaveBeenCalled();
expect(threadCommentModel.addComment).not.toHaveBeenCalled();
const next = (v: any) => v;
@@ -176,7 +175,7 @@ describe('DocThreadCommentRenderController', () => {
const outActiveWithForeignHover = handler({ id: 'c2' }, { unitId: 'doc-1' }, next);
expect(outActiveWithForeignHover.active).toBe(true);
hoveredCommentId$.next(threadCommentPanelService.hoveredCommentId);
expect(reRender).toHaveBeenCalledWith('doc-1');
expect(makeDirty).toHaveBeenCalledWith(true);
// resolved branch triggers rerender
threadCommentModel.getComment.mockImplementation((_unitId: string, _subUnitId: string, id: string) => {
@@ -196,11 +195,11 @@ describe('DocThreadCommentRenderController', () => {
// rerender on active comment changes
activeCommentId$.next({ unitId: 'doc-1', subUnitId: DEFAULT_DOC_SUBUNIT_ID, commentId: 'c1' });
expect(reRender).toHaveBeenCalledWith('doc-1');
expect(makeDirty).toHaveBeenCalledWith(true);
// rerender on resolve updates
commentUpdate$.next({ type: 'resolve', unitId: 'doc-1' });
expect(reRender).toHaveBeenCalledWith('doc-1');
expect(makeDirty).toHaveBeenCalledWith(true);
// trigger sync on rich text mutations (add new decoration)
// NOTE: `_initSyncComments` compares sorted thread ids. We emulate that the doc body returns a stable
@@ -241,6 +240,7 @@ describe('DocThreadCommentRenderController', () => {
const context = {
unitId: 'doc-1',
unit: { getUnitId: () => 'doc-1', getBody: () => ({ customDecorations: [] }) },
mainComponent: { makeDirty: vi.fn() },
scene,
engine: { onTransformChange$: { subscribeEvent: vi.fn(() => ({ dispose: vi.fn() })) } },
};
@@ -268,7 +268,6 @@ describe('DocThreadCommentRenderController', () => {
context as never,
{ intercept: vi.fn(() => ({ dispose: vi.fn() })) } as never,
panelService as never,
{ reRender: vi.fn() } as never,
{ getCurrentUnitOfType: vi.fn(() => context.unit) } as never,
commentModel as never,
{ executeCommand: vi.fn(() => Promise.resolve(true)), onCommandExecuted: vi.fn(() => ({ dispose: vi.fn() })) } as never,
@@ -30,7 +30,6 @@ import {
} from '@univerjs/core';
import { DOC_INTERCEPTOR_POINT, DocInterceptorService, RichTextEditingMutation } from '@univerjs/docs';
import { DEFAULT_DOC_SUBUNIT_ID } from '@univerjs/docs-thread-comment';
import { DocRenderController } from '@univerjs/docs-ui';
import { getDrawingShapeKeyByDrawingSearch, IDrawingManagerService } from '@univerjs/drawing';
import { deserializeThreadCommentAnchor, ThreadCommentAnchorKind, ThreadCommentModel } from '@univerjs/thread-comment';
import { ThreadCommentCanvasOverlay, ThreadCommentPanelService } from '@univerjs/thread-comment-ui';
@@ -48,7 +47,6 @@ export class DocThreadCommentRenderController extends Disposable implements IRen
private readonly _context: IRenderContext<DocumentDataModel>,
@Inject(DocInterceptorService) private readonly _docInterceptorService: DocInterceptorService,
@Inject(ThreadCommentPanelService) private readonly _threadCommentPanelService: ThreadCommentPanelService,
@Inject(DocRenderController) private readonly _docRenderController: DocRenderController,
@IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService,
@Inject(ThreadCommentModel) private readonly _threadCommentModel: ThreadCommentModel,
@ICommandService private readonly _commandService: ICommandService,
@@ -83,16 +81,14 @@ export class DocThreadCommentRenderController extends Disposable implements IRen
const currentUnitId = this._univerInstanceService
.getCurrentUnitOfType(UniverInstanceType.UNIVER_DOC)
?.getUnitId();
new Set([previous?.unitId, current?.unitId, currentUnitId]).forEach((unitId) => {
if (unitId) {
this._docRenderController.reRender(unitId);
}
});
if (new Set([previous?.unitId, current?.unitId, currentUnitId]).has(this._context.unitId)) {
this._context.mainComponent?.makeDirty(true);
}
})));
this.disposeWithMe(this._threadCommentModel.commentUpdate$.subscribe((update) => {
if (update.type === 'resolve') {
this._docRenderController.reRender(update.unitId);
if (update.type === 'resolve' && update.unitId === this._context.unitId) {
this._context.mainComponent?.makeDirty(true);
}
if (update.unitId === this._context.unitId) {
this._syncDrawingOverlay();
@@ -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 { DocumentEditArea } from '@univerjs/engine-render';
import { describe, expect, it, vi } from 'vitest';
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 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);
});
});
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 };
}
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);
});
it('selects an existing header before opening the sidebar', async () => {
const testBed = createOpenCommandTestBed('existing-header');
testBed.commandService.executeCommand.mockResolvedValueOnce(true);
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);
});
});
@@ -1626,7 +1626,16 @@ describe('misc document commands', () => {
});
it('updates document page setup through the real rich text mutation flow', async () => {
({ univer, get } = createCommandTestBed(createBaseDoc()));
const doc = createBaseDoc();
Object.assign(doc.body!.sectionBreaks![0], {
pageSize: { width: 500, height: 700 },
pageOrient: PageOrientType.PORTRAIT,
marginTop: 10,
marginBottom: 20,
marginLeft: 30,
marginRight: 40,
});
({ univer, get } = createCommandTestBed(doc));
commandService = get(ICommandService);
commandService.registerCommand(DocPageSetupCommand);
commandService.registerCommand(RichTextEditingMutation as unknown as ICommand);
@@ -1653,6 +1662,14 @@ describe('misc document commands', () => {
marginLeft: 48,
marginRight: 54,
}));
expect(getDoc()?.getBody()?.sectionBreaks?.[0]).toEqual(expect.objectContaining({
pageSize: PAGE_SIZE[PaperType.A3],
pageOrient: PageOrientType.LANDSCAPE,
marginTop: 36,
marginBottom: 42,
marginLeft: 48,
marginRight: 54,
}));
expect(mutationParams).toContainEqual(expect.objectContaining({
historyAction: DocHistoryAction.UpdatePageLayout,
}));
@@ -2034,18 +2051,60 @@ describe('misc document commands', () => {
});
it('opens and closes the header footer sidebar panel through commands and operations', async () => {
({ univer, get } = createCommandTestBed(createBaseDoc(), [
({ 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(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 },
@@ -19,6 +19,7 @@ import type { ICreateHeaderFooterCommandParams, IHeaderFooterProps } from '@univ
import {
BooleanNumber,
CommandType,
generateRandomId,
ICommandService,
IUniverInstanceService,
resolveSectionHeaderFooterReferences,
@@ -28,6 +29,7 @@ import { CreateHeaderFooterCommand, DocSelectionManagerService, DocSkeletonManag
import { DocumentEditArea, IRenderManagerService } from '@univerjs/engine-render';
import { findFirstCursorOffset } from '../../basics/selection';
import { DocSelectionRenderService } from '../../services/selection/doc-selection-render.service';
import { getHeaderFooterTarget } from '../../utils/section-header-footer';
import { SidebarDocHeaderFooterPanelOperation } from '../operations/doc-header-footer-panel.operation';
export interface ICoreHeaderFooterParams {
@@ -111,6 +113,53 @@ export const OpenHeaderFooterPanelCommand: ICommand<IOpenHeaderFooterPanelParams
handler: async (accessor, _params: IOpenHeaderFooterPanelParams) => {
const commandService = accessor.get(ICommandService);
const instanceService = accessor.get(IUniverInstanceService);
const renderManagerService = accessor.get(IRenderManagerService);
const docDataModel = instanceService.getCurrentUnitOfType<DocumentDataModel>(UniverInstanceType.UNIVER_DOC);
if (docDataModel == null) {
return false;
}
const unitId = docDataModel.getUnitId();
const renderObject = renderManagerService.getRenderUnitById(unitId);
const skeletonManager = renderObject?.with(DocSkeletonManagerService);
const viewModel = skeletonManager?.getViewModel();
const skeleton = skeletonManager?.getSkeleton();
const selectionRenderService = renderObject?.with(DocSelectionRenderService);
const pages = skeleton?.getSkeletonData()?.pages;
if (viewModel == null || selectionRenderService == null || !pages?.length) {
return false;
}
const currentPage = selectionRenderService.getSegmentPage();
const pageNumber = currentPage >= 0 && currentPage < pages.length ? currentPage : 0;
const page = pages[pageNumber];
viewModel.setEditArea(DocumentEditArea.HEADER);
const { createType, headerFooterId, sectionId } = getHeaderFooterTarget(
viewModel,
DocumentEditArea.HEADER,
pageNumber,
page
);
selectionRenderService.setSegmentPage(pageNumber);
if (createType != null) {
const segmentId = generateRandomId(6);
selectionRenderService.setSegment(segmentId);
const created = await commandService.executeCommand(CoreHeaderFooterCommand.id, {
unitId,
createType,
segmentId,
sectionId,
});
if (!created) {
selectionRenderService.setSegment('');
selectionRenderService.setSegmentPage(-1);
viewModel.setEditArea(DocumentEditArea.BODY);
return false;
}
} else if (headerFooterId != null) {
selectionRenderService.setSegment(headerFooterId);
}
return commandService.executeCommand(SidebarDocHeaderFooterPanelOperation.id, { value: 'open' });
},
@@ -168,7 +217,16 @@ export const CloseHeaderFooterCommand: ICommand<ICloseHeaderFooterParams> = {
docSelectionRenderService.setSegment('');
docSelectionRenderService.setSegmentPage(-1);
viewModel.setEditArea(DocumentEditArea.BODY);
skeleton.calculate();
// Switching edit areas does not change document content. Reuse the
// published body pages instead of synchronously paginating a large
// document again. An uninitialized skeleton still needs its first
// calculation.
const publishedPageCount = typeof skeleton.getSkeletonData === 'function'
? skeleton.getSkeletonData()?.pages.length ?? 0
: 0;
if (publishedPageCount === 0) {
skeleton.calculate();
}
renderObject.mainComponent?.makeDirty(true);
queueMicrotask(() => {
@@ -18,6 +18,7 @@ import type {
DocumentDataModel,
ICommand,
ICommandInfo,
IDocumentBody,
IDocumentStyle,
ISize,
ITable,
@@ -35,7 +36,7 @@ import {
MODERN_DOCUMENT_DEFAULT_MARGIN,
UniverInstanceType,
} from '@univerjs/core';
import { DocHistoryAction, RichTextEditingMutation } from '@univerjs/docs';
import { DocHistoryAction, getTopLevelSectionBreaks, RichTextEditingMutation } from '@univerjs/docs';
export interface IDocPageSetupCommandParams {
pageSize: ISize;
@@ -110,6 +111,27 @@ function resizePageFillTables(jsonX: JSONX, rawActions: NonNullable<JSONXActions
});
}
function updateTopLevelSectionPageSetup(
jsonX: JSONX,
rawActions: NonNullable<JSONXActions>,
body: IDocumentBody | null | undefined,
config: Pick<IDocumentStyle, 'marginBottom' | 'marginLeft' | 'marginRight' | 'marginTop' | 'pageOrient' | 'pageSize'>
) {
if (!body?.sectionBreaks?.length) {
return;
}
const sectionIds = new Set(getTopLevelSectionBreaks(body).map(({ sectionId }) => sectionId));
body.sectionBreaks.forEach((section, index) => {
if (!sectionIds.has(section.sectionId)) {
return;
}
const nextSection = { ...section, ...config };
const action = jsonX.replaceOp(['body', 'sectionBreaks', index], section, nextSection);
action && rawActions.push(action);
});
}
export const DocPageSetupCommand: ICommand<IDocPageSetupCommandParams> = {
id: 'docs.command.page-setup',
type: CommandType.COMMAND,
@@ -152,6 +174,19 @@ export const DocPageSetupCommand: ICommand<IDocPageSetupCommandParams> = {
getPageContentWidth(documentStyle),
getPageContentWidth(newDocumentStyle)
);
if (newDocumentStyle.documentFlavor !== DocumentFlavor.MODERN) {
// Imported DOCX files commonly store page geometry on every section.
// Updating only documentStyle leaves those explicit values in charge,
// so the Page Setup dialog appears to succeed without changing a page.
updateTopLevelSectionPageSetup(jsonX, rawActions, snapshot.body, {
marginBottom,
marginLeft,
marginRight,
marginTop,
pageOrient,
pageSize,
});
}
if (documentFlavor !== undefined) {
if (oldDocumentFlavor === undefined) {
@@ -49,8 +49,7 @@ export const DocOpenPageSettingCommand: ICommand = {
onClose: () => {
disposable.dispose();
},
onConfirm: (result) => {
disposable.dispose();
onConfirm: async (result) => {
if (!result) return;
const paperSize = result.mode === DocumentFlavor.MODERN
@@ -60,7 +59,7 @@ export const DocOpenPageSettingCommand: ICommand = {
}
: PAGE_SIZE[result.paperSize as PaperType];
commandService.executeCommand(DocPageSetupCommand.id, {
const applied = await commandService.executeCommand(DocPageSetupCommand.id, {
documentFlavor: result.mode,
pageOrient: result.orientation,
marginTop: result.margins.top,
@@ -69,6 +68,9 @@ export const DocOpenPageSettingCommand: ICommand = {
marginRight: result.margins.right,
pageSize: paperSize,
} as IDocPageSetupCommandParams);
if (applied) {
disposable.dispose();
}
},
confirmText: localeService.t<LocaleKey>('docs-ui.page-settings.confirm'),
cancelText: localeService.t<LocaleKey>('docs-ui.page-settings.cancel'),
@@ -18,7 +18,7 @@
import type { ICommandInfo, IExecutionOptions } from '@univerjs/core';
import type { IDocLayoutMountIdentity } from '@univerjs/docs';
import { DOCS_NORMAL_EDITOR_UNIT_ID_KEY, DocumentFlavor, JSONX, PositionedObjectLayoutType } from '@univerjs/core';
import { CustomDecorationType, CustomRangeType, DOCS_NORMAL_EDITOR_UNIT_ID_KEY, DocumentFlavor, JSONX, PositionedObjectLayoutType } from '@univerjs/core';
import { DocLayoutSessionStatus, RichTextEditingMutation } from '@univerjs/docs';
import { BehaviorSubject, Subject } from 'rxjs';
import { describe, expect, it, vi } from 'vitest';
@@ -239,6 +239,7 @@ function createControllerFixture(options?: {
publicationApplied = true;
return { didReplaceProtectedPages: options?.workerReplacesProtectedPages ?? false };
}),
getLayoutProgress: vi.fn(() => null),
hasCompleteLayout: vi.fn(() => options?.hasCompleteLayout ?? true),
findNodePositionByCharIndex: vi.fn(() => ({
page: publicationApplied
@@ -299,6 +300,9 @@ function createControllerFixture(options?: {
const context = {
unitId,
unit: {
documentStyle: {
documentFlavor: options?.documentFlavor ?? DocumentFlavor.TRADITIONAL,
},
getUnitId: vi.fn(() => unitId),
getSnapshot: vi.fn(() => ({
documentStyle: {
@@ -711,6 +715,9 @@ describe('doc render controller', () => {
} satisfies ICommandInfo);
expect(skeletonManager.getSkeleton().startIncrementalLayout).toHaveBeenCalledTimes(1);
expect(skeletonManager.getSkeleton().startIncrementalLayout).toHaveBeenCalledWith(expect.objectContaining({
deferForeground: true,
}));
});
it('reflows when a drawing switches between wrapping and overlay layout', () => {
@@ -844,6 +851,50 @@ describe('doc render controller', () => {
controller.dispose();
});
it('protects retained interaction pages when Main publishes only the edited page', async () => {
const { commandCallbacks, controller, skeletonManager } = createControllerFixture({
useWorker: true,
pages: Array.from({ length: 8 }, () => ({
pageWidth: 640,
pageHeight: 900,
sections: [{}],
skeDrawings: new Map(),
skeTables: new Map(),
})),
layoutProgress: [{
complete: false,
anchorReady: true,
didPublishAnchor: true,
interactionWindowComplete: true,
elapsedTime: 1,
pageCount: 20,
publishedPageCount: 1,
}],
});
const skeleton = skeletonManager.getSkeleton();
commandCallbacks[0]({
id: RichTextEditingMutation.id,
params: {
unitId: 'doc-unit',
actions: [],
},
} satisfies ICommandInfo);
await vi.waitFor(() => {
expect(skeleton.beginExternalLayout).toHaveBeenCalledWith({
reason: 'edit',
protectedRange: {
mode: 'paginated',
startPageIndex: 0,
endPageIndex: 4,
},
});
});
controller.dispose();
});
it('does not rebuild the active Main selection when the Worker only publishes the unprotected tail', async () => {
const { commandCallbacks, controller, selectionManager } = createControllerFixture({
useWorker: true,
@@ -1241,7 +1292,7 @@ describe('doc render controller', () => {
compositionEnd$.next({});
await vi.advanceTimersByTimeAsync(149);
expect(layoutExecutorService.startLayout).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
await vi.advanceTimersByTimeAsync(17);
expect(layoutExecutorService.startLayout).toHaveBeenCalledTimes(1);
controller.dispose();
@@ -1250,6 +1301,106 @@ describe('doc render controller', () => {
}
});
it('restarts a deferred interaction window when IME begins before the Worker handoff is queued', async () => {
vi.useFakeTimers();
try {
const { compositionEnd$, compositionStart$, controller, layoutExecutorService } = createControllerFixture({
useWorker: true,
pages: Array.from({ length: 5 }, () => ({
pageWidth: 640,
pageHeight: 900,
skeDrawings: new Map(),
skeTables: new Map(),
})),
layoutProgress: [{
complete: false,
anchorReady: true,
didPublishAnchor: true,
elapsedTime: 1,
pageCount: 20,
publishedPageCount: 1,
}],
});
controller.reRender('doc-unit', undefined, undefined, undefined, true, false, true);
compositionStart$.next({});
await vi.advanceTimersByTimeAsync(1_000);
expect(layoutExecutorService.startLayout).not.toHaveBeenCalled();
compositionEnd$.next({});
await vi.advanceTimersByTimeAsync(1_000);
expect(layoutExecutorService.startLayout).toHaveBeenCalledTimes(1);
controller.dispose();
} finally {
vi.useRealTimers();
}
});
it('does not restart a completed layout when IME composition makes no changes', async () => {
vi.useFakeTimers();
try {
const { compositionEnd$, compositionStart$, controller, skeletonManager } = createControllerFixture({
useWorker: true,
workerCompletes: true,
layoutProgress: [{
complete: true,
anchorReady: true,
didPublishAnchor: true,
elapsedTime: 1,
pageCount: 5,
publishedPageCount: 5,
}],
});
const skeleton = skeletonManager.getSkeleton();
controller.reRender('doc-unit', undefined, undefined, undefined, true, false, true);
await vi.advanceTimersByTimeAsync(1_000);
expect(skeleton.startIncrementalLayout).toHaveBeenCalledTimes(1);
compositionStart$.next({});
compositionEnd$.next({});
await vi.advanceTimersByTimeAsync(1_000);
expect(skeleton.startIncrementalLayout).toHaveBeenCalledTimes(1);
controller.dispose();
} finally {
vi.useRealTimers();
}
});
it('keeps a newer layout scheduled during IME instead of replaying the cancelled request', async () => {
vi.useFakeTimers();
try {
const { compositionEnd$, compositionStart$, controller, skeletonManager } = createControllerFixture({
useWorker: true,
layoutProgress: [{
complete: false,
anchorReady: true,
didPublishAnchor: true,
elapsedTime: 1,
pageCount: 20,
publishedPageCount: 1,
}],
});
const skeleton = skeletonManager.getSkeleton();
controller.reRender('doc-unit', 1, undefined, undefined, true, false, true);
compositionStart$.next({});
controller.reRender('doc-unit', 7, undefined, undefined, true, false, true);
compositionEnd$.next({});
await vi.advanceTimersByTimeAsync(1_000);
expect(skeleton.startIncrementalLayout).toHaveBeenCalledTimes(2);
expect(skeleton.startIncrementalLayout).toHaveBeenLastCalledWith(expect.objectContaining({
anchor: 7,
}));
controller.dispose();
} finally {
vi.useRealTimers();
}
});
it('does not hand the interaction window to the Worker during pointer selection', async () => {
vi.useFakeTimers();
try {
@@ -1290,9 +1441,7 @@ describe('doc render controller', () => {
expect(layoutExecutorService.startLayout).not.toHaveBeenCalled();
selectionRenderService.isOnPointerEvent = false;
await vi.advanceTimersByTimeAsync(149);
expect(layoutExecutorService.startLayout).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
await vi.advanceTimersByTimeAsync(150);
expect(layoutExecutorService.startLayout).toHaveBeenCalledTimes(1);
controller.dispose();
@@ -1376,7 +1525,7 @@ describe('doc render controller', () => {
commandCallbacks[0](mutation);
await vi.advanceTimersByTimeAsync(149);
expect(layoutExecutorService.startLayout).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
await vi.advanceTimersByTimeAsync(17);
expect(layoutExecutorService.startLayout).toHaveBeenCalledTimes(1);
@@ -1552,11 +1701,13 @@ describe('doc render controller', () => {
expect(skeletonManager.getSkeleton().startIncrementalLayout).toHaveBeenCalledWith({
reason: 'edit',
anchor: 1_792,
priorityAnchor: undefined,
invalidation: {
oldStart: 1_792,
oldEnd: 1_792,
newEnd: 1_793,
},
waitForHyphenationPatterns: true,
});
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
expect(viewport.scrollByViewportDeltaVal).not.toHaveBeenCalled();
@@ -1566,6 +1717,176 @@ describe('doc render controller', () => {
expect(backScrollController.scrollToRange).not.toHaveBeenCalled();
});
it('marks layout-metadata-only mutations as safe for structural tail reuse', () => {
const { commandCallbacks, controller, skeletonManager } = createControllerFixture({
pages: [
{
marginTop: 0,
pageWidth: 640,
pageHeight: 900,
sections: [],
skeDrawings: new Map(),
skeTables: new Map(),
},
{
isMaterializationPlaceholder: true,
marginTop: 0,
pageWidth: 640,
pageHeight: 900,
sections: [],
skeDrawings: new Map(),
skeTables: new Map(),
},
],
});
commandCallbacks[0]({
id: RichTextEditingMutation.id,
params: {
unitId: 'doc-unit',
textRanges: [{ startOffset: 104, endOffset: 104, collapsed: true, isActive: true }],
actions: ['body', {
et: 'text-x',
e: [
{ t: 'r', len: 100 },
{
t: 'r',
len: 4,
body: {
dataStream: '',
customRanges: [{
startIndex: 0,
endIndex: 3,
rangeId: 'link',
rangeType: CustomRangeType.HYPERLINK,
}],
},
oldBody: {
dataStream: '',
textRuns: [{ st: 0, ed: 4, ts: { ff: 'Arial', fs: 12 } }],
sectionBreaks: [],
customDecorations: [{ id: 'comment', type: 0, startIndex: 0, endIndex: 3 }],
customRanges: [],
},
coverType: 1,
},
],
}],
segmentId: '',
trigger: 'docs.command.add-hyper-link',
},
} satisfies ICommandInfo);
expect(skeletonManager.getSkeleton().startIncrementalLayout).toHaveBeenCalledWith(expect.objectContaining({
reason: 'edit',
anchor: 100,
priorityAnchor: 104,
invalidation: { oldStart: 100, oldEnd: 104, newEnd: 104 },
allowMetadataOnlyStructuralTailReuse: true,
}));
expect((controller as unknown as { _pendingWorkerEditBatch: unknown })._pendingWorkerEditBatch).toBeNull();
skeletonManager.getSkeleton().startIncrementalLayout.mockClear();
const undoCommand = {
id: RichTextEditingMutation.id,
params: {
unitId: 'doc-unit',
textRanges: [{ startOffset: 104, endOffset: 104, collapsed: true, isActive: true }],
actions: ['body', {
et: 'text-x',
e: [
{ t: 'r', len: 100 },
{
t: 'r',
len: 4,
body: {
dataStream: '',
textRuns: [{ st: 0, ed: 4, ts: { ff: 'Arial', fs: 12 } }],
sectionBreaks: [],
customDecorations: [{ id: 'comment', type: 0, startIndex: 0, endIndex: 3 }],
customRanges: [],
},
oldBody: {
dataStream: '',
textRuns: [{ st: 0, ed: 4, ts: { ff: 'Arial', fs: 12 } }],
sectionBreaks: [],
customDecorations: [{ id: 'comment', type: 0, startIndex: 0, endIndex: 3 }],
customRanges: [{
startIndex: 0,
endIndex: 3,
rangeId: 'link',
rangeType: CustomRangeType.HYPERLINK,
}],
},
coverType: 1,
},
],
}],
segmentId: '',
trigger: 'univer.command.undo',
},
} satisfies ICommandInfo;
commandCallbacks[0](undoCommand);
expect(skeletonManager.getSkeleton().startIncrementalLayout).toHaveBeenCalledWith(expect.objectContaining({
allowMetadataOnlyStructuralTailReuse: true,
}));
expect((controller as unknown as { _pendingWorkerEditBatch: unknown })._pendingWorkerEditBatch).toBeNull();
skeletonManager.getSkeleton().startIncrementalLayout.mockClear();
const formattingCommand = structuredClone(undoCommand);
const formattingAction = (formattingCommand.params.actions[1] as {
e: Array<{ oldBody?: { textRuns?: Array<{ ts: { fs?: number } }> } }>;
}).e[1];
formattingAction.oldBody!.textRuns![0].ts.fs = 14;
commandCallbacks[0](formattingCommand);
expect(skeletonManager.getSkeleton().startIncrementalLayout).not.toHaveBeenCalledWith(expect.objectContaining({
allowMetadataOnlyStructuralTailReuse: true,
}));
skeletonManager.getSkeleton().startIncrementalLayout.mockClear();
commandCallbacks[0]({
id: RichTextEditingMutation.id,
params: {
unitId: 'doc-unit',
textRanges: null,
actions: ['body', {
et: 'text-x',
e: [
{ t: 'r', len: 100 },
{
t: 'r',
len: 4,
body: {
dataStream: '',
customDecorations: [{
id: 'comment',
type: CustomDecorationType.COMMENT,
startIndex: 0,
endIndex: 3,
}],
},
oldBody: {
dataStream: '',
textRuns: [{ st: 0, ed: 4, ts: { ff: 'Arial', fs: 12 } }],
sectionBreaks: [],
customDecorations: [],
customRanges: [],
},
},
],
}],
noNeedSetTextRange: true,
trigger: 'docs.command.add-comment',
},
} satisfies ICommandInfo);
expect(skeletonManager.getSkeleton().startIncrementalLayout).toHaveBeenCalledWith(expect.objectContaining({
allowMetadataOnlyStructuralTailReuse: true,
}));
controller.dispose();
});
it('uses the pretransformed local caret when a remote edit still carries sender ranges', () => {
const { commandCallbacks, controller, skeletonManager } = createControllerFixture({
useWorker: true,
@@ -1606,6 +1927,7 @@ describe('doc render controller', () => {
},
preserveInteractionWindow: true,
reuseUnaffectedTail: false,
waitForHyphenationPatterns: true,
});
controller.dispose();
@@ -1651,6 +1973,7 @@ describe('doc render controller', () => {
},
preserveInteractionWindow: false,
reuseUnaffectedTail: false,
waitForHyphenationPatterns: true,
});
controller.dispose();
@@ -14,11 +14,9 @@
* limitations under the License.
*/
import type { DocumentDataModel, ICommandInfo, Nullable } from '@univerjs/core';
import type { DocumentDataModel, ICommandInfo } from '@univerjs/core';
import type {
Documents,
DocumentViewModel,
IDocumentSkeletonPage,
IMouseEvent,
IPageRenderConfig,
IPathProps,
@@ -29,7 +27,6 @@ import type {
} from '@univerjs/engine-render';
import type { LocaleKey } from '../locale/types';
import {
BooleanNumber,
ColorKit,
Disposable,
DocumentFlavor,
@@ -42,77 +39,18 @@ import {
toDisposable,
UniverInstanceType,
} from '@univerjs/core';
import { DocSkeletonManagerService, HeaderFooterType, RichTextEditingMutation } from '@univerjs/docs';
import { DocSkeletonManagerService, RichTextEditingMutation } from '@univerjs/docs';
import { DocumentEditArea, IRenderManagerService, PageLayoutType, Path, Rect, Vector2 } from '@univerjs/engine-render';
import { neoGetDocObject } from '../basics/component-tools';
import { CloseHeaderFooterCommand, CoreHeaderFooterCommand } from '../commands/commands/doc-header-footer.command';
import { IEditorService } from '../services/editor/editor-manager.service';
import { DocSelectionRenderService } from '../services/selection/doc-selection-render.service';
import { getDocPageSectionContext } from '../utils/section-header-footer';
import { getHeaderFooterTarget } from '../utils/section-header-footer';
import { TextBubbleShape } from '../views/header-footer/text-bubble';
const HEADER_FOOTER_COVER_ALPHA = 0.5;
const HEADER_FOOTER_LABEL_ALPHA = 0.08;
interface IHeaderFooterCreate {
createType: Nullable<HeaderFooterType>;
headerFooterId: Nullable<string>;
sectionId?: string;
}
function checkCreateHeaderFooterType(
viewModel: DocumentViewModel,
editArea: DocumentEditArea,
segmentPage: number,
page?: IDocumentSkeletonPage
): IHeaderFooterCreate {
const snapshot = viewModel.getDataModel().getSnapshot();
const { sectionId, config } = getDocPageSectionContext(snapshot, page);
const {
defaultHeaderId,
defaultFooterId,
evenPageHeaderId,
evenPageFooterId,
firstPageHeaderId,
firstPageFooterId,
evenAndOddHeaders,
useFirstPageHeaderFooter,
} = config;
const isFirstPage = page ? page.pageNumber === page.pageNumberStart : segmentPage === 0;
const isEvenPage = page ? page.pageNumber % 2 === 0 : segmentPage % 2 === 1;
if (editArea === DocumentEditArea.BODY) {
return { createType: null, headerFooterId: null, sectionId };
}
const isHeader = editArea === DocumentEditArea.HEADER;
if (!isHeader && editArea !== DocumentEditArea.FOOTER) {
throw new Error(`Invalid editArea: ${editArea}`);
}
const variants = isHeader
? {
first: [firstPageHeaderId, HeaderFooterType.FIRST_PAGE_HEADER] as const,
even: [evenPageHeaderId, HeaderFooterType.EVEN_PAGE_HEADER] as const,
default: [defaultHeaderId, HeaderFooterType.DEFAULT_HEADER] as const,
}
: {
first: [firstPageFooterId, HeaderFooterType.FIRST_PAGE_FOOTER] as const,
even: [evenPageFooterId, HeaderFooterType.EVEN_PAGE_FOOTER] as const,
default: [defaultFooterId, HeaderFooterType.DEFAULT_FOOTER] as const,
};
const [headerFooterId, createType] = useFirstPageHeaderFooter === BooleanNumber.TRUE && isFirstPage
? variants.first
: evenAndOddHeaders === BooleanNumber.TRUE && isEvenPage
? variants.even
: variants.default;
return {
createType: headerFooterId ? null : createType,
headerFooterId: headerFooterId ?? null,
sectionId,
};
}
export class DocHeaderFooterController extends Disposable implements IRenderModule {
private _loadedMap = new WeakSet<RenderComponentType>();
private _headerFooterColors = {
@@ -262,7 +200,7 @@ export class DocHeaderFooterController extends Disposable implements IRenderModu
viewModel.setEditArea(editArea);
const page = skeleton.getSkeletonData()?.pages[pageNumber];
const { createType, headerFooterId, sectionId } = checkCreateHeaderFooterType(viewModel, editArea, pageNumber, page);
const { createType, headerFooterId, sectionId } = getHeaderFooterTarget(viewModel, editArea, pageNumber, page);
if (editArea === DocumentEditArea.BODY) {
this._docSelectionRenderService.setSegment('');
@@ -0,0 +1,258 @@
/**
* 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 type { DocumentDataModel, IDocumentData } from '@univerjs/core';
import type { IPointerEvent, RenderUnit } from '@univerjs/engine-render';
import { DocumentFlavor, ICommandService, IUniverInstanceService, Univer, UniverInstanceType } from '@univerjs/core';
import { DocLayoutExecutorService, DocSelectionManagerService, DocSkeletonManagerService, SetTextSelectionsOperation } from '@univerjs/docs';
import {
CanvasColorService,
Documents,
ICanvasColorService,
IRenderManagerService,
RenderManagerService,
ScrollBar,
Viewport,
} from '@univerjs/engine-render';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { VIEWPORT_KEY } from '../../../basics/docs-view-key';
import { EditorService, IEditorService } from '../../../services/editor/editor-manager.service';
import { DocBackScrollRenderController } from '../back-scroll.render-controller';
function createEditor() {
const univer = new Univer();
const injector = univer.__getInjector();
injector.add([IRenderManagerService, { useClass: RenderManagerService }]);
injector.add([ICanvasColorService, { useClass: CanvasColorService }]);
injector.add([DocLayoutExecutorService]);
injector.add([DocSelectionManagerService]);
injector.add([IEditorService, { useClass: EditorService }]);
injector.get(ICommandService).registerCommand(SetTextSelectionsOperation);
const dataStream = `${Array.from({ length: 100 }, (_, i) => `Paragraph ${i} has enough text to wrap onto several lines.\r`).join('')}\n`;
const model = univer.createUnit<IDocumentData, DocumentDataModel>(UniverInstanceType.UNIVER_DOC, {
id: 'back-scroll-test',
body: {
dataStream,
paragraphs: [...dataStream.matchAll(/\r/g)].map((match, index) => ({
startIndex: match.index!,
paragraphId: `paragraph-${index}`,
})),
sectionBreaks: [{ startIndex: dataStream.length - 1, sectionId: 'body' }],
},
documentStyle: {
documentFlavor: DocumentFlavor.TRADITIONAL,
pageSize: { width: 300, height: 400 },
marginLeft: 20,
marginRight: 20,
marginTop: 20,
marginBottom: 20,
},
});
const unitId = model.getUnitId();
injector.get(IUniverInstanceService).setCurrentUnitForType(unitId);
const render = injector.get(IRenderManagerService).createRender(unitId) as RenderUnit;
render.deactivate();
render.engine.resizeBySize(400, 250);
render.addRenderDependencies([[DocSkeletonManagerService]]);
const skeleton = render.with(DocSkeletonManagerService).getSkeleton();
const pages = skeleton.getSkeletonData()!.pages;
expect(pages.length).toBeGreaterThan(4);
const height = pages.reduce((sum, page) => sum + page.pageHeight + 20, 0);
render.scene.transformByState({ width: 400, height });
const documents = new Documents('doc-main', skeleton, { pageMarginTop: 20, pageMarginLeft: 0 });
documents.resize(300, height);
render.mainComponent = documents;
render.scene.addObject(documents);
const viewport = new Viewport(VIEWPORT_KEY.VIEW_MAIN, render.scene, {
left: 0,
top: 0,
width: 400,
height: 250,
active: true,
});
new ScrollBar(viewport, { mainScene: render.scene });
viewport.resetCanvasSizeAndUpdateScroll();
expect(viewport.isActive).toBe(true);
render.addRenderDependencies([[DocBackScrollRenderController]]);
const controller = render.with(DocBackScrollRenderController);
const selections = injector.get(DocSelectionManagerService);
return {
controller,
render,
skeleton,
viewport,
select(offset: number, isEditing = true): void {
selections.__TEST_ONLY_add([{ startOffset: offset, endOffset: offset, collapsed: true, isActive: true }], isEditing);
selections.__replaceTextRangesWithNoRefresh({
...selections.getSelectionInfo()!,
textRanges: [{ startOffset: offset, endOffset: offset, collapsed: true, isActive: true }],
rectRanges: [],
isEditing,
}, { unitId, subUnitId: unitId });
expect(selections.getActiveTextRange()?.startOffset).toBe(offset);
},
dispose(): void {
univer.dispose();
},
};
}
describe('DocBackScrollRenderController', () => {
beforeEach(() => {
vi.useFakeTimers();
const context = 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(context as never);
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
it('keeps a new manual scroll instead of replaying an earlier input scroll', async () => {
const editor = createEditor();
try {
editor.select(3);
editor.viewport.scrollToViewportPos({ viewportScrollY: 1200 });
const userScroll = editor.viewport.viewportScrollY;
expect(userScroll).toBeGreaterThan(1000);
await vi.advanceTimersByTimeAsync(20);
expect(editor.viewport.viewportScrollY).toBe(userScroll);
} finally {
editor.dispose();
}
});
it('does not reuse an input scroll for a newer non-editing selection', async () => {
const editor = createEditor();
try {
editor.select(3);
editor.select(editor.skeleton.getSkeletonData()!.pages[4].st, false);
await vi.advanceTimersByTimeAsync(20);
expect(editor.viewport.viewportScrollY).toBe(0);
} finally {
editor.dispose();
}
});
it('still follows typing when no newer interaction takes ownership', async () => {
const editor = createEditor();
try {
editor.viewport.scrollToViewportPos({ viewportScrollY: 1200 });
expect(editor.viewport.viewportScrollY).toBeGreaterThan(1000);
editor.select(3);
await vi.advanceTimersByTimeAsync(20);
expect(editor.viewport.viewportScrollY).toBe(0);
} finally {
editor.dispose();
}
});
it('does not let a later layout refresh reclaim a manually scrolled viewport', async () => {
const editor = createEditor();
try {
editor.select(3);
editor.viewport.scrollToViewportPos({ viewportScrollY: 1200 });
await vi.advanceTimersByTimeAsync(20);
editor.select(3);
await vi.advanceTimersByTimeAsync(20);
expect(editor.viewport.viewportScrollY).toBe(1200);
editor.select(4);
await vi.advanceTimersByTimeAsync(20);
expect(editor.viewport.viewportScrollY).toBe(0);
} finally {
editor.dispose();
}
});
it('cancels an earlier input scroll as soon as a pointer selection starts', async () => {
const editor = createEditor();
try {
editor.viewport.scrollToViewportPos({ viewportScrollY: 1200 });
editor.select(3);
editor.render.scene.onPointerDown$.emitEvent({ offsetX: 40, offsetY: 40, button: 0 } as IPointerEvent);
await vi.advanceTimersByTimeAsync(20);
expect(editor.viewport.viewportScrollY).toBe(1200);
} finally {
editor.dispose();
}
});
it('cancels a materialization retry when the user scrolls elsewhere', async () => {
const editor = createEditor();
try {
const pages = editor.skeleton.getSkeletonData()!.pages;
const original = pages[4];
pages[4] = { ...original, isMaterializationPlaceholder: true, sections: [], skeTables: new Map() };
editor.controller.scrollToRange({ startOffset: original.st, endOffset: original.st, collapsed: true });
expect(editor.viewport.viewportScrollY).toBeGreaterThan(1200);
editor.viewport.scrollToViewportPos({ viewportScrollY: 1200 });
pages[4] = original;
await vi.advanceTimersByTimeAsync(20);
expect(editor.viewport.viewportScrollY).toBe(1200);
} finally {
editor.dispose();
}
});
it('waits for a usable document viewport before revealing an SDK range', async () => {
const editor = createEditor();
try {
const documentComponent = editor.render.mainComponent as Documents;
const target = editor.skeleton.getSkeletonData()!.pages[4].st;
editor.select(3);
await vi.advanceTimersByTimeAsync(20);
documentComponent.translate(-10000, -10000);
editor.controller.scrollToRange({ startOffset: target, endOffset: target, collapsed: true });
// A resize/layout refresh can replay the pre-request selection while
// the real viewport is becoming ready. It must not steal ownership.
editor.select(3, false);
await vi.advanceTimersByTimeAsync(20);
expect(editor.viewport.viewportScrollY).toBe(0);
documentComponent.translate(0, 20);
await vi.advanceTimersByTimeAsync(20);
expect(editor.viewport.viewportScrollY).toBeGreaterThan(1000);
} finally {
editor.dispose();
}
});
it('releases an outstanding input scroll on disposal', async () => {
const editor = createEditor();
try {
editor.viewport.scrollToViewportPos({ viewportScrollY: 1200 });
editor.select(3);
editor.controller.dispose();
await vi.advanceTimersByTimeAsync(20);
expect(editor.viewport.viewportScrollY).toBe(1200);
} finally {
editor.dispose();
}
});
});
@@ -0,0 +1,156 @@
/**
* 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 { IDocumentLayoutProgress } from '@univerjs/engine-render';
import { BehaviorSubject, Subject } from 'rxjs';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
DOC_LAYOUT_PROGRESS_CANCEL_GRACE_MS,
DOC_LAYOUT_PROGRESS_DELAY_MS,
DocLayoutProgressRenderController,
resolveDocLayoutProgressPercent,
} from '../doc-layout-progress.render-controller';
function createProgress(overrides: Partial<IDocumentLayoutProgress> = {}): IDocumentLayoutProgress {
return {
anchorReady: true,
cancelled: false,
complete: false,
didPublish: true,
didPublishAnchor: true,
elapsedTime: 10,
estimatedHeight: 1_000,
estimatedPageCount: 10,
generation: 1,
laidOutThrough: 20,
maxBlockDuration: 1,
mode: 'paginated',
pageCount: 2,
processedBlockCount: 20,
publicationRevision: 1,
publishedPageCount: 2,
reason: 'initial',
stableLaidOutThrough: 20,
totalBlockCount: 100,
...overrides,
};
}
function createController(initiallyComplete = false) {
const layoutProgress$ = new Subject<IDocumentLayoutProgress>();
const skeleton = {
hasCompleteLayout: () => initiallyComplete,
layoutProgress$,
};
const currentSkeleton$ = new BehaviorSubject(skeleton);
const setProgress = vi.fn();
const clearProgress = vi.fn();
const controller = new DocLayoutProgressRenderController(
{
unitId: 'doc-1',
} as never,
{ currentSkeleton$ } as never,
{ clearProgress, setProgress } as never
);
clearProgress.mockClear();
return { clearProgress, controller, layoutProgress$, setProgress };
}
describe('document layout progress', () => {
afterEach(() => {
vi.useRealTimers();
});
it('publishes footer progress only after a slow initial layout exceeds two seconds', () => {
vi.useFakeTimers();
const { clearProgress, controller, layoutProgress$, setProgress } = createController();
layoutProgress$.next(createProgress());
vi.advanceTimersByTime(DOC_LAYOUT_PROGRESS_DELAY_MS - 1);
expect(setProgress).not.toHaveBeenCalled();
vi.advanceTimersByTime(1);
expect(setProgress).toHaveBeenLastCalledWith('doc-1', 20);
layoutProgress$.next(createProgress({ processedBlockCount: 10 }));
expect(setProgress).toHaveBeenLastCalledWith('doc-1', 20);
layoutProgress$.next(createProgress({ complete: true, processedBlockCount: 100 }));
expect(clearProgress).toHaveBeenCalledOnce();
controller.dispose();
});
it('does not flash for fast initial layout or later edits', () => {
vi.useFakeTimers();
const { controller, layoutProgress$, setProgress } = createController();
layoutProgress$.next(createProgress());
vi.advanceTimersByTime(500);
layoutProgress$.next(createProgress({ complete: true, processedBlockCount: 100 }));
vi.advanceTimersByTime(DOC_LAYOUT_PROGRESS_DELAY_MS);
layoutProgress$.next(createProgress({ reason: 'edit' }));
vi.advanceTimersByTime(DOC_LAYOUT_PROGRESS_DELAY_MS);
expect(setProgress).not.toHaveBeenCalled();
controller.dispose();
});
it('keeps the original delay across a cancelled Main to Worker handoff', () => {
vi.useFakeTimers();
const { controller, layoutProgress$, setProgress } = createController();
layoutProgress$.next(createProgress());
vi.advanceTimersByTime(DOC_LAYOUT_PROGRESS_DELAY_MS - 100);
layoutProgress$.next(createProgress({ cancelled: true }));
vi.advanceTimersByTime(100);
expect(setProgress).not.toHaveBeenCalled();
layoutProgress$.next(createProgress({ generation: 2, processedBlockCount: 30 }));
expect(setProgress).toHaveBeenLastCalledWith('doc-1', 30);
controller.dispose();
});
it('clears visible progress when a cancelled generation has no successor', () => {
vi.useFakeTimers();
const { clearProgress, controller, layoutProgress$, setProgress } = createController();
layoutProgress$.next(createProgress());
vi.advanceTimersByTime(DOC_LAYOUT_PROGRESS_DELAY_MS);
expect(setProgress).toHaveBeenCalledOnce();
layoutProgress$.next(createProgress({ cancelled: true }));
vi.advanceTimersByTime(DOC_LAYOUT_PROGRESS_CANCEL_GRACE_MS - 1);
expect(clearProgress).not.toHaveBeenCalled();
vi.advanceTimersByTime(1);
expect(clearProgress).toHaveBeenCalledOnce();
controller.dispose();
});
it('calculates bounded progress from blocks and falls back to pages', () => {
expect(resolveDocLayoutProgressPercent(createProgress({ processedBlockCount: 25 }))).toBe(25);
expect(resolveDocLayoutProgressPercent(createProgress({ processedBlockCount: 200 }))).toBe(99);
expect(resolveDocLayoutProgressPercent(createProgress({
estimatedPageCount: 8,
pageCount: 4,
processedBlockCount: 0,
publishedPageCount: 2,
totalBlockCount: 0,
}))).toBe(25);
expect(resolveDocLayoutProgressPercent(createProgress({ complete: true }))).toBe(100);
});
});
@@ -14,9 +14,16 @@
* limitations under the License.
*/
import { EventSubject } from '@univerjs/core';
import { Subject } from 'rxjs';
import type { DocumentDataModel, IDocumentData } from '@univerjs/core';
import type { RenderUnit } from '@univerjs/engine-render';
import { DocumentFlavor, IUniverInstanceService, Univer, UniverInstanceType } from '@univerjs/core';
import { DocLayoutExecutorService, DocSelectionManagerService, DocSkeletonManagerService } from '@univerjs/docs';
import { CanvasColorService, DocBackground, Documents, ICanvasColorService, IRenderManagerService, RenderManagerService } from '@univerjs/engine-render';
import { DesktopSidebarService, ISidebarService } from '@univerjs/ui';
import { describe, expect, it, vi } from 'vitest';
import { DOCS_VIEW_KEY } from '../../../basics/docs-view-key';
import { DocPageLayoutService } from '../../../services/doc-page-layout.service';
import { DocViewScaleService } from '../../../services/doc-view-scale';
import { DocResizeRenderController, hasRenderableDocSkeleton } from '../doc-resize.render-controller';
describe('hasRenderableDocSkeleton', () => {
@@ -34,35 +41,68 @@ describe('hasRenderableDocSkeleton', () => {
});
describe('DocResizeRenderController', () => {
it('refreshes page layout and text selection after sidebar layout changes', async () => {
const calculatePagePosition = vi.fn();
const refreshRanges = vi.fn();
const refreshSelection = vi.fn();
const sidebarOptions$ = new Subject();
const controller = new DocResizeRenderController(
{
unitId: 'doc-1',
engine: {
onTransformChange$: new EventSubject(),
it('refreshes the host logical selection even while a comment editor owns the current selection', async () => {
vi.useFakeTimers();
const context = new Proxy({
font: '',
measureText: (text: string) => ({ width: text.length * 8, actualBoundingBoxAscent: 8, actualBoundingBoxDescent: 2 }),
}, { get: (target, key) => key in target ? Reflect.get(target, key) : () => {} });
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(context as never);
const univer = new Univer();
try {
const injector = univer.__getInjector();
injector.add([IRenderManagerService, { useClass: RenderManagerService }]);
injector.add([ICanvasColorService, { useClass: CanvasColorService }]);
injector.add([ISidebarService, { useClass: DesktopSidebarService }]);
injector.add([DocLayoutExecutorService]);
injector.add([DocSelectionManagerService]);
const unitId = 'resize-host';
univer.createUnit<IDocumentData, DocumentDataModel>(UniverInstanceType.UNIVER_DOC, {
id: unitId,
body: {
dataStream: 'Host text\r\n',
paragraphs: [{ startIndex: 9, paragraphId: 'host-paragraph' }],
sectionBreaks: [{ startIndex: 10, sectionId: 'host-section' }],
},
mainComponent: {
getSkeleton: () => ({ getSkeletonData: () => ({ pages: [{}] }) }),
},
} as never,
{ calculatePagePosition } as never,
{ refreshSelection } as never,
{ refreshRanges } as never,
{ sidebarOptions$ } as never
);
documentStyle: { documentFlavor: DocumentFlavor.TRADITIONAL, pageSize: { width: 300, height: 400 } },
});
injector.get(IUniverInstanceService).setCurrentUnitForType(unitId);
const render = injector.get(IRenderManagerService).createRender(unitId) as RenderUnit;
render.deactivate();
render.engine.resizeBySize(800, 600);
render.addRenderDependencies([[DocSkeletonManagerService], [DocViewScaleService], [DocPageLayoutService]]);
const skeleton = render.with(DocSkeletonManagerService).getSkeleton();
const documents = new Documents('host-doc', skeleton);
documents.resize(300, 400);
render.mainComponent = documents;
render.scene.addObject(documents);
render.components.set(DOCS_VIEW_KEY.BACKGROUND, new DocBackground('host-background', skeleton));
const selections = injector.get(DocSelectionManagerService);
selections.__TEST_ONLY_setCurrentSelection({ unitId, subUnitId: unitId });
selections.__TEST_ONLY_add([{ startOffset: 1, endOffset: 4, collapsed: false }]);
selections.__TEST_ONLY_setCurrentSelection({ unitId: 'comment-editor', subUnitId: 'comment-editor' });
const refreshes: Array<{ unitId: string; offsets: number[] }> = [];
const subscription = selections.refreshSelection$.subscribe((event) => {
if (event) {
refreshes.push({ unitId: event.unitId, offsets: event.docRanges.map((range) => range.startOffset) });
}
});
render.addRenderDependencies([[DocResizeRenderController]]);
render.with(DocResizeRenderController);
await vi.advanceTimersByTimeAsync(20);
refreshes.length = 0;
injector.get(ISidebarService).open({ children: { label: 'comment-panel' }, width: 320 });
await vi.advanceTimersByTimeAsync(20);
sidebarOptions$.next({ visible: true });
expect(calculatePagePosition).not.toHaveBeenCalled();
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
expect(calculatePagePosition).toHaveBeenCalledOnce();
expect(refreshRanges).toHaveBeenCalledOnce();
expect(refreshSelection).toHaveBeenCalledOnce();
controller.dispose();
expect(refreshes).toContainEqual({ unitId, offsets: [1] });
expect(refreshes.every((event) => event.unitId === unitId)).toBe(true);
expect(selections.__getCurrentSelection()?.unitId).toBe('comment-editor');
expect(documents.left).toBeGreaterThan(0);
subscription.unsubscribe();
} finally {
univer.dispose();
vi.restoreAllMocks();
vi.useRealTimers();
}
});
});
@@ -18,7 +18,11 @@
import type { EmbedInteractionBoundaryService } from '../../../services/doc-embed-integration.service';
import { DOCS_NORMAL_EDITOR_UNIT_ID_KEY } from '@univerjs/core';
import { CURSOR_TYPE, DocumentEditArea } from '@univerjs/engine-render';
import {
CURSOR_TYPE,
DocumentEditArea,
NORMAL_TEXT_SELECTION_PLUGIN_STYLE,
} from '@univerjs/engine-render';
import { Subject } from 'rxjs';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { SetDocZoomRatioOperation } from '../../../commands/operations/set-doc-zoom-ratio.operation';
@@ -108,6 +112,7 @@ function createController(options: { readonly?: boolean; hasEditor?: boolean; pr
__handleDblClick: vi.fn(),
__handleTripleClick: vi.fn(),
setCursorManually: vi.fn(),
getActiveTextRange: vi.fn(() => ({ startOffset: 0, endOffset: 0 })),
isOnPointerEvent: false,
};
const docSelectionManagerService = {
@@ -116,6 +121,7 @@ function createController(options: { readonly?: boolean; hasEditor?: boolean; pr
__getCurrentSelection: vi.fn(() => ({ unitId: options.currentSelectionUnitId ?? options.unitId ?? 'doc-1' })),
refreshSelection: vi.fn(),
replaceDocRanges: vi.fn(),
replaceSelectionInfoWithoutRefresh: vi.fn(),
};
const editor = options.hasEditor
? { isReadOnly: vi.fn(() => options.readonly ?? false) }
@@ -211,12 +217,28 @@ describe('DocSelectionRenderController', () => {
commandHandlers[0]({ id: SetDocZoomRatioOperation.id, params: { unitId: 'doc-1' } });
expect(docSelectionRenderService.focus).toHaveBeenCalled();
expect(docSelectionManagerService.replaceDocRanges).toHaveBeenCalledWith(
[{ startOffset: 3, endOffset: 3 }],
expect(docSelectionManagerService.replaceSelectionInfoWithoutRefresh).toHaveBeenCalledWith(
{
textRanges: [{
startOffset: 3,
endOffset: 3,
collapsed: true,
isActive: true,
}],
rectRanges: [],
segmentId: '',
segmentPage: -1,
isEditing: false,
style: NORMAL_TEXT_SELECTION_PLUGIN_STYLE,
},
{ unitId: 'doc-1', subUnitId: 'doc-1' }
);
expect(docSelectionManagerService.refreshSelection).toHaveBeenNthCalledWith(
1,
{ unitId: 'doc-1', subUnitId: 'doc-1' },
false
);
expect(docSelectionManagerService.refreshSelection).toHaveBeenCalledTimes(1);
expect(docSelectionManagerService.refreshSelection).toHaveBeenNthCalledWith(2);
controller.dispose();
});
@@ -349,6 +371,7 @@ describe('DocSelectionRenderController', () => {
expect(docSelectionRenderService.focus).not.toHaveBeenCalled();
expect(docSelectionManagerService.replaceDocRanges).not.toHaveBeenCalled();
expect(docSelectionManagerService.replaceSelectionInfoWithoutRefresh).not.toHaveBeenCalled();
lease.dispose();
controller.dispose();
@@ -0,0 +1,470 @@
/**
* 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 type { DocumentDataModel, IDocumentData } from '@univerjs/core';
import type { IDocLayoutExecutor } from '@univerjs/docs';
import type { Documents, IPointerEvent, RenderUnit } from '@univerjs/engine-render';
import {
BooleanNumber,
DataStreamTreeTokenType,
DocumentFlavor,
DrawingTypeEnum,
HorizontalAlign,
ICommandService,
IUniverInstanceService,
ObjectRelativeFromH,
ObjectRelativeFromV,
PositionedObjectLayoutType,
Univer,
UniverInstanceType,
} from '@univerjs/core';
import {
DocLayoutExecutorService,
DocSelectionManagerService,
DocSkeletonManagerService,
DocStateEmitService,
InsertTextCommand,
RichTextEditingMutation,
SetTextSelectionsOperation,
} from '@univerjs/docs';
import {
CanvasColorService,
ICanvasColorService,
IRenderManagerService,
RenderManagerService,
} from '@univerjs/engine-render';
import { ILayoutService } from '@univerjs/ui';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { VIEWPORT_KEY } from '../../../basics/docs-view-key';
import { IMEInputCommand } from '../../../commands/commands/ime-input.command';
import { DocIMEInputManagerService } from '../../../services/doc-ime-input-manager.service';
import { DocLayoutInteractionService } from '../../../services/doc-layout-interaction.service';
import { DocMenuStyleService } from '../../../services/doc-menu-style.service';
import { DocPageLayoutService } from '../../../services/doc-page-layout.service';
import { DocViewScaleService } from '../../../services/doc-view-scale';
import { EditorService, IEditorService } from '../../../services/editor/editor-manager.service';
import { DocSelectionRenderService } from '../../../services/selection/doc-selection-render.service';
import { cursorConvertToTextRange } from '../../../services/selection/text-range';
import { DocBackScrollRenderController } from '../back-scroll.render-controller';
import { DocIMEInputController } from '../doc-ime-input.controller';
import { DocInputController } from '../doc-input.controller';
import { DocSelectionRenderController } from '../doc-selection-render.controller';
import { DocRenderController } from '../doc.render-controller';
function createEditor(paragraphCount = 8, withDrawing = true, workerBeforeLayout = false) {
const univer = new Univer();
const injector = univer.__getInjector();
const root = document.createElement('div');
document.body.appendChild(root);
injector.add([ILayoutService, { useValue: {
rootContainerElement: root,
registerContainerElement: () => ({ dispose() {} }),
} as unknown as ILayoutService }]);
injector.add([IRenderManagerService, { useClass: RenderManagerService }]);
injector.add([ICanvasColorService, { useClass: CanvasColorService }]);
injector.add([DocLayoutExecutorService]);
// The transport boundary stays pending; the real controller and coordinator
// must still hand off before Main paginates the entire document.
const registerWorker = () => injector.get(DocLayoutExecutorService).register({
type: 'worker',
initialize: () => new Promise<void>(() => {}),
disposeSession: async () => {},
} as unknown as IDocLayoutExecutor);
let registration = workerBeforeLayout ? registerWorker() : undefined;
const startWorkerLayout = vi.spyOn(injector.get(DocLayoutExecutorService), 'startLayout');
injector.add([DocSelectionManagerService]);
injector.add([DocStateEmitService]);
injector.add([DocMenuStyleService]);
injector.add([IEditorService, { useClass: EditorService }]);
const commands = injector.get(ICommandService);
[InsertTextCommand, IMEInputCommand, RichTextEditingMutation, SetTextSelectionsOperation]
.forEach((command) => commands.registerCommand(command));
const firstParagraph = 'Hello world';
const drawingToken = withDrawing ? DataStreamTreeTokenType.CUSTOM_BLOCK : '';
const dataStream = `${firstParagraph}\r${drawingToken}${Array.from({ length: paragraphCount }, (_, i) => `Paragraph ${i} has enough words to wrap across several lines.\r`).join('')}\n`;
const model = univer.createUnit<IDocumentData, DocumentDataModel>(UniverInstanceType.UNIVER_DOC, {
id: 'bounded-caret-test',
// A drawing on the edited page deliberately keeps the conservative
// deferred path covered, independently of font-specific line wrapping.
drawings: withDrawing
? {
'inline-drawing': {
drawingId: 'inline-drawing',
unitId: 'bounded-caret-test',
subUnitId: 'bounded-caret-test',
drawingType: DrawingTypeEnum.DRAWING_BLOCK,
layoutType: PositionedObjectLayoutType.INLINE,
docTransform: {
angle: 0,
positionH: { relativeFrom: ObjectRelativeFromH.PAGE, posOffset: 0 },
positionV: { relativeFrom: ObjectRelativeFromV.PARAGRAPH, posOffset: 0 },
size: { width: 30, height: 20 },
},
},
}
: {},
body: {
dataStream,
customBlocks: withDrawing ? [{ blockId: 'inline-drawing', startIndex: firstParagraph.length + 1 }] : [],
paragraphs: [...dataStream.matchAll(/\r/g)].map((match, index) => ({
startIndex: match.index!,
paragraphId: `paragraph_${index}`,
paragraphStyle: { horizontalAlign: HorizontalAlign.CENTER },
})),
sectionBreaks: [{ startIndex: dataStream.length - 1, sectionId: 'section_test' }],
},
documentStyle: {
...(workerBeforeLayout ? { autoHyphenation: BooleanNumber.FALSE } : {}),
documentFlavor: DocumentFlavor.TRADITIONAL,
pageSize: { width: 300, height: 700 },
textStyle: { ff: 'Arial', fs: 14 },
marginLeft: 20,
marginRight: 20,
marginTop: 20,
marginBottom: 20,
},
});
const unitId = model.getUnitId();
const instances = injector.get(IUniverInstanceService);
instances.setCurrentUnitForType(unitId);
instances.focusUnit(unitId);
const render = injector.get(IRenderManagerService).createRender(unitId) as RenderUnit;
render.engine.resizeBySize(800, 600);
render.deactivate();
render.addRenderDependencies([
[DocSkeletonManagerService],
[DocSelectionRenderService],
[DocViewScaleService],
[DocPageLayoutService],
[DocLayoutInteractionService],
[DocRenderController],
[DocBackScrollRenderController],
[DocSelectionRenderController],
[DocInputController],
[DocIMEInputManagerService],
[DocIMEInputController],
]);
const selection = render.with(DocSelectionRenderService);
const selectionManager = injector.get(DocSelectionManagerService);
const skeleton = render.with(DocSkeletonManagerService).getSkeleton();
if (!workerBeforeLayout) {
const firstLine = skeleton.getSkeletonData()!.pages[0].sections[0].columns[0].lines[0];
expect(firstLine.ed).toBe(firstParagraph.length);
expect(firstLine.lineHeight).toBeGreaterThan(0);
expect(skeleton.getSkeletonData()!.pages[0].skeDrawings.size).toBe(withDrawing ? 1 : 0);
}
// Worker transport is unavailable in jsdom. Keep it uninitialized so the
// real Main coordinator must publish the bounded interaction window.
registration ??= registerWorker();
if (!workerBeforeLayout) {
selection.replaceDocRanges([{ startOffset: 5, endOffset: 5 }], true, { shouldFocus: false });
}
const input = root.querySelector<HTMLDivElement>('[data-u-comp="editor"]')!;
return {
commands,
input,
model,
render,
selection,
selectionManager,
skeleton,
startWorkerLayout,
unitId,
dispose(): void {
registration?.dispose();
univer.dispose();
root.remove();
},
};
}
describe('DocRenderController bounded input publication', () => {
beforeEach(() => {
vi.useFakeTimers();
const context = 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(context as never);
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
vi.useRealTimers();
});
it.each([1, 500])('opens %i paragraphs without waiting for full Main pagination', async (paragraphCount) => {
vi.stubGlobal('requestIdleCallback', (callback: IdleRequestCallback) =>
window.setTimeout(() => callback({ didTimeout: false, timeRemaining: () => 10 }), 0));
vi.stubGlobal('cancelIdleCallback', (id: number) => window.clearTimeout(id));
const editor = createEditor(paragraphCount, false, true);
try {
await vi.advanceTimersByTimeAsync(1_000);
expect(editor.startWorkerLayout).toHaveBeenCalledTimes(paragraphCount === 1 ? 0 : 1);
expect(editor.skeleton.hasCompleteLayout()).toBe(paragraphCount === 1);
const pages = editor.skeleton.getSkeletonData()!.pages;
expect(pages.length).toBeGreaterThan(0);
expect(pages.length).toBeLessThanOrEqual(5);
expect(pages.every((page) => page.sections.length > 0)).toBe(true);
} finally {
editor.dispose();
}
});
it('publishes the initial caret as soon as the first foreground page is ready', async () => {
const editor = createEditor(500, false, true);
try {
expect(editor.selection.getActiveTextRange()).toBeUndefined();
expect(document.activeElement).toBe(editor.input);
await vi.advanceTimersByTimeAsync(1_000);
expect(editor.skeleton.hasCompleteLayout()).toBe(false);
expect(editor.skeleton.getSkeletonData()!.pages.length).toBeLessThanOrEqual(5);
expect(editor.selection.getActiveTextRange()).toMatchObject({
startOffset: 0,
endOffset: 0,
});
} finally {
editor.dispose();
}
});
it.each(['A', '中'])('publishes the %s caret with its page without an intermediate wrong coordinate', async (text) => {
const editor = createEditor();
try {
// Initial synchronous layout can initiate code-split dictionaries.
// Resolve that fixture setup before measuring one input frame.
await vi.dynamicImportSettled();
const before = editor.selection.getActiveTextRange()!.getAnchor()!.left;
const calculate = vi.spyOn(editor.skeleton, 'calculate');
const steps = vi.spyOn(editor.skeleton, 'stepIncrementalLayout');
await editor.commands.executeCommand(InsertTextCommand.id, {
unitId: editor.unitId,
range: { startOffset: 5, endOffset: 5, collapsed: true },
body: { dataStream: text },
});
expect(editor.skeleton.getLayoutProgress()?.anchorReady).toBe(false);
expect(calculate).not.toHaveBeenCalled();
expect(steps.mock.calls.length).toBeLessThanOrEqual(4);
expect(editor.selectionManager.getActiveTextRange()?.endOffset).toBe(6);
expect(editor.selection.getActiveTextRange()!.getAnchor()!.left).toBe(before);
await vi.advanceTimersByTimeAsync(20);
const expected = cursorConvertToTextRange(
editor.render.scene,
{ startOffset: 6, endOffset: 6 },
editor.skeleton,
editor.render.mainComponent as Documents
)!;
expect(editor.selection.getActiveTextRange()!.getAnchor()!.left).toBe(expected.getAnchor()!.left);
expect(editor.selection.getActiveTextRange()?.endOffset).toBe(6);
expected.dispose();
} finally {
editor.dispose();
}
});
it('keeps consecutive native input ordered before the next layout frame', async () => {
const editor = createEditor();
try {
for (const text of ['A', 'B', 'C']) {
editor.input.textContent = text;
editor.input.dispatchEvent(new InputEvent('input', { data: text, inputType: 'insertText' }));
await Promise.resolve();
await Promise.resolve();
expect(editor.skeleton.getLayoutProgress()?.anchorReady).toBe(false);
}
expect(editor.model.getBody()?.dataStream.startsWith('HelloABC world')).toBe(true);
expect(editor.selectionManager.getActiveTextRange()?.endOffset).toBe(8);
await vi.advanceTimersByTimeAsync(20);
expect(editor.selection.getActiveTextRange()?.endOffset).toBe(8);
} finally {
editor.dispose();
}
});
it.each(['A', '中'])('publishes page-first %s text and caret before waiting for a frame', async (text) => {
const editor = createEditor(30, false);
try {
const previousSkeleton = editor.skeleton.getSkeletonData();
const steps = vi.spyOn(editor.skeleton, 'stepIncrementalLayout');
await editor.commands.executeCommand(InsertTextCommand.id, {
unitId: editor.unitId,
range: { startOffset: 5, endOffset: 5, collapsed: true },
body: { dataStream: text },
});
expect(editor.model.getBody()?.dataStream.startsWith(`Hello${text} world`)).toBe(true);
expect(editor.skeleton.getSkeletonData()).not.toBe(previousSkeleton);
expect(editor.skeleton.findNodeByCharIndex(5)?.content).toContain(text);
expect(steps.mock.calls.length).toBeLessThanOrEqual(4);
expect(editor.selection.getActiveTextRange()?.endOffset).toBe(6);
expect(editor.selection.hasPendingSelection).toBe(false);
const expected = cursorConvertToTextRange(
editor.render.scene,
{ startOffset: 6, endOffset: 6 },
editor.skeleton,
editor.render.mainComponent as Documents
)!;
expect(editor.selection.getActiveTextRange()!.getAnchor()).toMatchObject({
left: expected.getAnchor()!.left,
top: expected.getAnchor()!.top,
height: expected.getAnchor()!.height,
});
expected.dispose();
} finally {
editor.dispose();
}
});
it('does not restore a queued edit caret over a newer selection', async () => {
const editor = createEditor();
try {
editor.commands.syncExecuteCommand(InsertTextCommand.id, {
unitId: editor.unitId,
range: { startOffset: 5, endOffset: 5, collapsed: true },
body: { dataStream: 'A' },
});
expect(editor.skeleton.getLayoutProgress()?.anchorReady).toBe(false);
editor.selection.replaceDocRanges([{ startOffset: 2, endOffset: 2 }], true, { shouldFocus: false });
await Promise.resolve();
expect(editor.selectionManager.getActiveTextRange()?.endOffset).toBe(2);
await vi.advanceTimersByTimeAsync(20);
expect(editor.selectionManager.getActiveTextRange()?.endOffset).toBe(2);
} finally {
editor.dispose();
}
});
it('uses a newer selection for native input while the edited page is pending', async () => {
const editor = createEditor();
try {
await editor.commands.executeCommand(InsertTextCommand.id, {
unitId: editor.unitId,
range: { startOffset: 5, endOffset: 5, collapsed: true },
body: { dataStream: 'A' },
});
expect(editor.skeleton.getLayoutProgress()?.anchorReady).toBe(false);
editor.selection.replaceDocRanges([{ startOffset: 2, endOffset: 2 }], true, { shouldFocus: false });
editor.input.textContent = 'Z';
editor.input.dispatchEvent(new InputEvent('input', { data: 'Z', inputType: 'insertText' }));
await Promise.resolve();
expect(editor.model.getBody()?.dataStream.startsWith('HeZlloA world')).toBe(true);
await vi.advanceTimersByTimeAsync(20);
expect(editor.selectionManager.getActiveTextRange()?.endOffset).toBe(3);
} finally {
editor.dispose();
}
});
it('starts IME from a pending logical caret and commits the composed text once', async () => {
const editor = createEditor();
try {
editor.input.textContent = 'A';
editor.input.dispatchEvent(new InputEvent('input', { data: 'A', inputType: 'insertText' }));
await Promise.resolve();
await Promise.resolve();
expect(editor.skeleton.getLayoutProgress()?.anchorReady).toBe(false);
editor.input.dispatchEvent(new CompositionEvent('compositionstart', { data: '' }));
for (const text of ['n', 'ni', '你']) {
editor.input.dispatchEvent(new CompositionEvent('compositionupdate', { data: text }));
for (let index = 0; index < 8; index++) {
await Promise.resolve();
}
}
editor.input.dispatchEvent(new CompositionEvent('compositionend', { data: '你' }));
for (let index = 0; index < 8; index++) {
await Promise.resolve();
}
expect(editor.model.getBody()?.dataStream.startsWith('HelloA你 world')).toBe(true);
await vi.advanceTimersByTimeAsync(20);
expect(editor.selectionManager.getActiveTextRange()?.endOffset).toBe(7);
expect(editor.selection.getActiveTextRange()?.endOffset).toBe(7);
} finally {
editor.dispose();
}
});
it('does not restore an old input position after a pointer targets an unpublished page', async () => {
const editor = createEditor();
try {
const target = cursorConvertToTextRange(
editor.render.scene,
{ startOffset: 2, endOffset: 2 },
editor.skeleton,
editor.render.mainComponent as Documents
)!;
const anchor = target.getAnchor()!;
const event = { offsetX: anchor.left, offsetY: anchor.top + 3, button: 0 } as IPointerEvent;
target.dispose();
await editor.commands.executeCommand(InsertTextCommand.id, {
unitId: editor.unitId,
range: { startOffset: 5, endOffset: 5, collapsed: true },
body: { dataStream: 'A' },
});
editor.selection.__onPointDown(event);
expect(editor.skeleton.getLayoutProgress()?.anchorReady).toBe(false);
expect(editor.selection.isOnPointerEvent).toBe(false);
expect(editor.selectionManager.getActiveTextRange()).toBeUndefined();
editor.input.textContent = 'B';
editor.input.dispatchEvent(new InputEvent('input', { data: 'B', inputType: 'insertText' }));
await vi.advanceTimersByTimeAsync(20);
expect(editor.selection.getActiveTextRange()?.endOffset).toBeUndefined();
editor.render.scene.onPointerUp$.emitEvent(event);
expect(editor.selectionManager.getActiveTextRange()).toBeUndefined();
expect(editor.model.getBody()?.dataStream.startsWith('HelloA world')).toBe(true);
expect(editor.model.getBody()?.dataStream.includes('B')).toBe(false);
} finally {
editor.dispose();
}
});
it('keeps a manual scroll through delayed edit publication and resumes following new input', async () => {
const editor = createEditor(100);
try {
const viewport = editor.render.scene.getViewport(VIEWPORT_KEY.VIEW_MAIN)!;
expect(viewport.isActive).toBe(true);
await editor.commands.executeCommand(InsertTextCommand.id, {
unitId: editor.unitId,
range: { startOffset: 5, endOffset: 5, collapsed: true },
body: { dataStream: 'A' },
});
expect(editor.skeleton.getLayoutProgress()?.anchorReady).toBe(false);
viewport.scrollToViewportPos({ viewportScrollY: 1200 });
const manualScroll = viewport.viewportScrollY;
expect(manualScroll).toBeGreaterThan(1000);
await vi.advanceTimersByTimeAsync(100);
expect(viewport.viewportScrollY).toBe(manualScroll);
expect(editor.selectionManager.getActiveTextRange()?.endOffset).toBe(6);
editor.input.textContent = 'B';
editor.input.dispatchEvent(new InputEvent('input', { data: 'B', inputType: 'insertText' }));
await vi.advanceTimersByTimeAsync(100);
expect(editor.model.getBody()?.dataStream.startsWith('HelloAB world')).toBe(true);
expect(editor.selectionManager.getActiveTextRange()?.endOffset).toBe(7);
expect(viewport.viewportScrollY).toBeLessThan(manualScroll);
} finally {
editor.dispose();
}
});
});
@@ -25,9 +25,16 @@ import { NodePositionConvertToCursor } from '../../services/selection/convert-te
import { getAnchorBounding } from '../../services/selection/text-range';
const ANCHOR_WIDTH = 1.5;
// Large imported documents can take tens of seconds to publish an offset's
// aggregate page. The request remains cancellable by real user ownership.
const MAX_SELECTION_SCROLL_RETRY_FRAMES = 6000;
export class DocBackScrollRenderController extends RxDisposable implements EngineRender.IRenderModule {
private _pendingSelectionScrollFrame: number | null = null;
private _suppressedSelection: Nullable<ITextRangeParam> = null;
private _pendingExplicitRange: Nullable<ITextRangeParam> = null;
private _selectionBeforeExplicitRange: Nullable<ITextRangeParam> = null;
private _scrollingToRange = false;
constructor(
private readonly _context: EngineRender.IRenderContext<DocumentDataModel>,
@@ -41,14 +48,37 @@ export class DocBackScrollRenderController extends RxDisposable implements Engin
}
private _init() {
const viewport = this._context.scene.getViewport(VIEWPORT_KEY.VIEW_MAIN);
if (viewport != null) {
let scrollX = viewport.viewportScrollX;
let scrollY = viewport.viewportScrollY;
this.disposeWithMe(viewport.onScrollAfter$.subscribeEvent((event) => {
const changed = event.viewportScrollX !== scrollX || event.viewportScrollY !== scrollY;
scrollX = event.viewportScrollX;
scrollY = event.viewportScrollY;
if (changed && !this._scrollingToRange) {
this._suspendSelectionScroll();
}
}));
}
this.disposeWithMe(this._context.scene.onPointerDown$.subscribeEvent(() => this._suspendSelectionScroll()));
this._textSelectionManagerService.textSelection$.pipe(takeUntil(this.dispose$)).subscribe((params) => {
if (params == null) {
return;
}
const { isEditing, unitId } = params;
const range = params.textRanges.find((item) => item.isActive);
if (unitId !== this._context.unitId || !isEditing) {
if (unitId !== this._context.unitId) {
return;
}
if (!isEditing) {
if (this._ownsPendingExplicitRange(range)) {
return;
}
this._suspendSelectionScroll();
return;
}
@@ -56,16 +86,51 @@ export class DocBackScrollRenderController extends RxDisposable implements Engin
return;
}
if (this._ownsPendingExplicitRange(range)) {
return;
}
this._clearExplicitRangeOwnership();
const suppressed = this._suppressedSelection;
if (range != null && suppressed != null &&
range.startOffset === suppressed.startOffset && range.endOffset === suppressed.endOffset &&
(range.segmentId ?? '') === (suppressed.segmentId ?? '') &&
(range.segmentPage ?? -1) === (suppressed.segmentPage ?? -1)) {
return;
}
this._suppressedSelection = null;
this._scheduleScrollToSelection();
});
}
override dispose(): void {
this._cancelPendingSelectionScroll();
this._suppressedSelection = null;
this._clearExplicitRangeOwnership();
super.dispose();
}
private _suspendSelectionScroll(): void {
this._cancelPendingSelectionScroll();
this._clearExplicitRangeOwnership();
const range = this._textSelectionManagerService.getActiveTextRange();
// A later layout publication can refresh the same logical caret. It must
// not reclaim a viewport the user has since scrolled or selected in.
this._suppressedSelection = range == null
? null
: {
startOffset: range.startOffset,
endOffset: range.endOffset,
collapsed: range.collapsed,
segmentId: range.segmentId,
segmentPage: range.segmentPage,
};
}
private _cancelPendingSelectionScroll(): void {
if (this._pendingSelectionScrollFrame != null && typeof cancelAnimationFrame !== 'undefined') {
cancelAnimationFrame(this._pendingSelectionScrollFrame);
}
this._pendingSelectionScrollFrame = null;
super.dispose();
}
private _scheduleScrollToSelection(): void {
@@ -83,11 +148,49 @@ export class DocBackScrollRenderController extends RxDisposable implements Engin
});
}
scrollToRange(range: ITextRangeParam) {
scrollToRange(range: ITextRangeParam, onResolved?: () => void): void {
this._cancelPendingSelectionScroll();
if (this._pendingExplicitRange == null) {
this._selectionBeforeExplicitRange = this._textSelectionManagerService.getActiveTextRange();
}
this._pendingExplicitRange = range;
this._scrollingToRange = true;
try {
this._scrollToRange(range, onResolved);
} finally {
this._scrollingToRange = false;
if (this._pendingSelectionScrollFrame == null) {
this._clearExplicitRangeOwnership();
}
}
}
private _isSameRange(left: Nullable<ITextRangeParam>, right: Nullable<ITextRangeParam>): boolean {
return left != null && right != null &&
left.startOffset === right.startOffset && left.endOffset === right.endOffset &&
(left.segmentId ?? '') === (right.segmentId ?? '') &&
(left.segmentPage ?? -1) === (right.segmentPage ?? -1);
}
private _ownsPendingExplicitRange(range: Nullable<ITextRangeParam>): boolean {
return this._pendingExplicitRange != null &&
(range == null || this._isSameRange(range, this._pendingExplicitRange) || this._isSameRange(range, this._selectionBeforeExplicitRange));
}
private _clearExplicitRangeOwnership(): void {
this._pendingExplicitRange = null;
this._selectionBeforeExplicitRange = null;
}
private _scrollToRange(range: ITextRangeParam, onResolved?: () => void): void {
const skeleton = this._docSkeletonManagerService.getSkeleton();
if (!skeleton) {
return;
}
if (!this.isViewportReady()) {
this._scheduleScrollToReadyRange(range, 0, onResolved);
return;
}
const { startOffset, segmentId, segmentPage } = range;
const anchorNodePosition = skeleton.findNodePositionByCharIndex(
startOffset,
@@ -96,12 +199,7 @@ export class DocBackScrollRenderController extends RxDisposable implements Engin
segmentPage ?? -1
);
if (anchorNodePosition == null) {
const pageIndex = skeleton.findBodyPageIndexByCharIndex(startOffset);
const page = skeleton.getSkeletonData()?.pages[pageIndex];
if (page?.isMaterializationPlaceholder) {
this._scrollToPage(pageIndex);
this._scheduleScrollToMaterializedRange(range);
}
this._deferUnresolvedRange(skeleton, range, onResolved);
return;
}
@@ -117,10 +215,26 @@ export class DocBackScrollRenderController extends RxDisposable implements Engin
}
this.scrollToNode(anchorNodePosition);
onResolved?.();
}
private _scheduleScrollToMaterializedRange(range: ITextRangeParam, attempt = 0): void {
if (typeof requestAnimationFrame === 'undefined' || attempt >= 120) {
private _deferUnresolvedRange(
skeleton: ReturnType<DocSkeletonManagerService['getSkeleton']>,
range: ITextRangeParam,
onResolved?: () => void
): void {
const pageIndex = skeleton.findBodyPageIndexByCharIndex(range.startOffset);
const page = skeleton.getSkeletonData()?.pages[pageIndex];
if (page?.isMaterializationPlaceholder) {
this._scrollToPage(pageIndex);
this._scheduleScrollToReadyRange(range, 0, onResolved);
} else if (pageIndex < 0 && this._isValidBodyOffset(skeleton, range.startOffset)) {
this._scheduleScrollToReadyRange(range, 0, onResolved);
}
}
private _scheduleScrollToReadyRange(range: ITextRangeParam, attempt = 0, onResolved?: () => void): void {
if (typeof requestAnimationFrame === 'undefined' || attempt >= MAX_SELECTION_SCROLL_RETRY_FRAMES) {
return;
}
if (this._pendingSelectionScrollFrame != null) {
@@ -130,14 +244,36 @@ export class DocBackScrollRenderController extends RxDisposable implements Engin
this._pendingSelectionScrollFrame = null;
const skeleton = this._docSkeletonManagerService.getSkeleton();
const pageIndex = skeleton.findBodyPageIndexByCharIndex(range.startOffset);
if (skeleton.getSkeletonData()?.pages[pageIndex]?.isMaterializationPlaceholder) {
this._scheduleScrollToMaterializedRange(range, attempt + 1);
if (!this.isViewportReady() || (pageIndex < 0 && this._isValidBodyOffset(skeleton, range.startOffset))) {
this._scheduleScrollToReadyRange(range, attempt + 1, onResolved);
return;
}
this.scrollToRange(range);
this.scrollToRange(range, onResolved);
});
}
private _isValidBodyOffset(skeleton: ReturnType<DocSkeletonManagerService['getSkeleton']>, offset: number): boolean {
const length = skeleton.getViewModel().getDataModel().getBody()?.dataStream.length ?? 0;
return offset >= 0 && offset < length;
}
isViewportReady(): boolean {
const { mainComponent, scene } = this._context;
if (!(mainComponent instanceof EngineRender.Documents)) {
return true;
}
const { docsLeft, docsTop } = mainComponent.getOffsetConfig();
if (docsLeft <= -10000 || docsTop <= -10000) {
return false;
}
const viewport = scene.getViewport(VIEWPORT_KEY.VIEW_MAIN);
if (viewport == null) {
return true;
}
const viewBound = viewport.calcViewportInfo().viewBound;
return viewBound.right - viewBound.left > 1 && viewBound.bottom - viewBound.top > 1;
}
private _scrollToPage(pageIndex: number): void {
const { mainComponent, scene } = this._context;
const skeleton = this._docSkeletonManagerService.getSkeleton();
@@ -0,0 +1,167 @@
/**
* 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, Nullable } from '@univerjs/core';
import type { IDocumentLayoutProgress, IRenderContext, IRenderModule } from '@univerjs/engine-render';
import { Inject, RxDisposable } from '@univerjs/core';
import { DocSkeletonManagerService } from '@univerjs/docs';
import { EMPTY, switchMap, takeUntil } from 'rxjs';
import { DocLayoutProgressService } from '../../services/doc-layout-progress.service';
export const DOC_LAYOUT_PROGRESS_DELAY_MS = 2_000;
export const DOC_LAYOUT_PROGRESS_CANCEL_GRACE_MS = 1_000;
export function resolveDocLayoutProgressPercent(progress: IDocumentLayoutProgress): number {
if (progress.complete) {
return 100;
}
if (progress.totalBlockCount > 0) {
return Math.min(99, Math.max(0, Math.floor(progress.processedBlockCount / progress.totalBlockCount * 100)));
}
const estimatedPageCount = Math.max(progress.estimatedPageCount, progress.pageCount);
if (estimatedPageCount > 0) {
return Math.min(99, Math.max(0, Math.floor(progress.publishedPageCount / estimatedPageCount * 100)));
}
return 0;
}
export class DocLayoutProgressRenderController extends RxDisposable implements IRenderModule {
private _visible = false;
private _delayTimer: Nullable<ReturnType<typeof setTimeout>> = null;
private _cancelTimer: Nullable<ReturnType<typeof setTimeout>> = null;
private _latestProgress: Nullable<IDocumentLayoutProgress> = null;
private _layoutStartedAt: Nullable<number> = null;
private _maxProgress = 0;
private _hasCompletedInitialLayout = false;
constructor(
private readonly _context: IRenderContext<DocumentDataModel>,
@Inject(DocSkeletonManagerService) private readonly _docSkeletonManagerService: DocSkeletonManagerService,
@Inject(DocLayoutProgressService) private readonly _docLayoutProgressService: DocLayoutProgressService
) {
super();
this._docSkeletonManagerService.currentSkeleton$.pipe(
switchMap((skeleton) => {
this._reset(skeleton?.hasCompleteLayout() ?? false);
return skeleton?.layoutProgress$ ?? EMPTY;
}),
takeUntil(this.dispose$)
).subscribe((progress) => this._handleProgress(progress));
}
override dispose(): void {
this._reset(true);
super.dispose();
}
private _handleProgress(progress: IDocumentLayoutProgress): void {
if (progress.cancelled) {
this._latestProgress = null;
this._scheduleCancelledClear();
return;
}
this._clearCancelTimer();
if (progress.complete) {
this._reset(true);
return;
}
if (this._hasCompletedInitialLayout) {
return;
}
this._latestProgress = progress;
this._layoutStartedAt ??= Date.now();
this._maxProgress = Math.max(this._maxProgress, resolveDocLayoutProgressPercent(progress));
if (this._visible) {
this._docLayoutProgressService.setProgress(this._context.unitId, this._maxProgress);
}
this._scheduleShow();
}
private _scheduleShow(): void {
if (this._visible || this._delayTimer != null || this._layoutStartedAt == null) {
return;
}
const remainingDelay = Math.max(0, DOC_LAYOUT_PROGRESS_DELAY_MS - (Date.now() - this._layoutStartedAt));
if (remainingDelay === 0) {
this._show();
return;
}
this._delayTimer = setTimeout(() => {
this._delayTimer = null;
this._show();
}, remainingDelay);
}
private _show(): void {
if (
this._visible ||
this._latestProgress == null ||
this._latestProgress.complete ||
this._hasCompletedInitialLayout
) {
return;
}
this._visible = true;
this._docLayoutProgressService.setProgress(this._context.unitId, this._maxProgress);
}
private _scheduleCancelledClear(): void {
if (!this._visible || this._cancelTimer != null) {
return;
}
this._cancelTimer = setTimeout(() => {
this._cancelTimer = null;
if (this._latestProgress != null) {
return;
}
this._visible = false;
this._docLayoutProgressService.clearProgress(this._context.unitId);
}, DOC_LAYOUT_PROGRESS_CANCEL_GRACE_MS);
}
private _clearCancelTimer(): void {
if (this._cancelTimer != null) {
clearTimeout(this._cancelTimer);
this._cancelTimer = null;
}
}
private _reset(hasCompletedInitialLayout: boolean): void {
if (this._delayTimer != null) {
clearTimeout(this._delayTimer);
this._delayTimer = null;
}
this._clearCancelTimer();
this._docLayoutProgressService.clearProgress(this._context.unitId);
this._visible = false;
this._latestProgress = null;
this._layoutStartedAt = null;
this._maxProgress = 0;
this._hasCompletedInitialLayout = hasCompletedInitialLayout;
}
}
@@ -20,7 +20,6 @@ import { DocSelectionManagerService } from '@univerjs/docs';
import { ISidebarService } from '@univerjs/ui';
import { animationFrameScheduler, observeOn, throttleTime } from 'rxjs';
import { DocPageLayoutService } from '../../services/doc-page-layout.service';
import { DocSelectionRenderService } from '../../services/selection/doc-selection-render.service';
export function hasRenderableDocSkeleton(component: unknown): boolean {
if (typeof component !== 'object' || component == null ||
@@ -45,13 +44,14 @@ export class DocResizeRenderController extends Disposable implements IRenderModu
private _context: IRenderContext,
@Inject(DocPageLayoutService) private readonly _docPageLayoutService: DocPageLayoutService,
@Inject(DocSelectionManagerService) private readonly _textSelectionManagerService: DocSelectionManagerService,
@Inject(DocSelectionRenderService) private readonly _docSelectionRenderService: DocSelectionRenderService,
@ISidebarService private readonly _sidebarService: ISidebarService
) {
super();
const unitId = this._context.unitId;
if (isInternalEditorID(unitId)) return this;
if (isInternalEditorID(unitId)) {
return this;
}
this._initResize();
}
@@ -82,7 +82,10 @@ export class DocResizeRenderController extends Disposable implements IRenderModu
}
this._docPageLayoutService.calculatePagePosition();
this._docSelectionRenderService.refreshRanges();
this._textSelectionManagerService.refreshSelection();
// Sidebar changes can run after a new layout checkpoint. Rebuild from
// this document's logical offsets, not physical nodes from the old page.
// The current global selection may belong to the comment editor instead.
const unitId = this._context.unitId;
this._textSelectionManagerService.refreshSelection({ unitId, subUnitId: unitId });
}
}
@@ -33,7 +33,14 @@ import {
UniverInstanceType,
} from '@univerjs/core';
import { DocSelectionManagerService, DocSkeletonManagerService } from '@univerjs/docs';
import { CURSOR_TYPE, DocumentEditArea, PageLayoutType, Vector2 } from '@univerjs/engine-render';
import {
CURSOR_TYPE,
DocumentEditArea,
NORMAL_TEXT_SELECTION_PLUGIN_STYLE,
PageLayoutType,
Vector2,
} from '@univerjs/engine-render';
import { filter, take } from 'rxjs';
import { neoGetDocObject } from '../../basics/component-tools';
import { findFirstCursorOffset } from '../../basics/selection';
import { SetDocZoomRatioOperation } from '../../commands/operations/set-doc-zoom-ratio.operation';
@@ -63,10 +70,10 @@ export class DocSelectionRenderController extends Disposable implements IRenderM
private _initialize() {
this._init();
this._skeletonListener();
this._commandExecutedListener();
this._refreshListener();
this._syncSelection();
this._skeletonListener();
this._commandExecutedListener();
}
private _init() {
@@ -339,16 +346,32 @@ export class DocSelectionRenderController extends Disposable implements IRenderM
const docDataModel = this._context.unit;
const snapshot = docDataModel.getSnapshot();
const offset = findFirstCursorOffset(snapshot);
this._docSelectionManagerService.replaceDocRanges([
{
startOffset: offset,
endOffset: offset,
},
], {
const selectionTarget = {
unitId,
subUnitId: unitId,
}, false);
};
this._docSelectionManagerService.replaceSelectionInfoWithoutRefresh({
textRanges: [{
startOffset: offset,
endOffset: offset,
collapsed: true,
isActive: true,
}],
rectRanges: [],
segmentId: '',
segmentPage: -1,
isEditing: false,
style: NORMAL_TEXT_SELECTION_PLUGIN_STYLE,
}, selectionTarget);
this._docSelectionManagerService.refreshSelection(selectionTarget, false);
if (this._docSelectionRenderService.getActiveTextRange() == null) {
this.disposeWithMe(skeleton.layoutProgress$
.pipe(
filter((progress) => progress?.anchorReady === true || progress?.complete === true),
take(1)
)
.subscribe(() => this._docSelectionManagerService.refreshSelection(selectionTarget, false)));
}
}
}));
}
@@ -14,14 +14,69 @@
* limitations under the License.
*/
import type { DocumentDataModel, EventState, ICommandInfo, IDocDrawingBase, IExecutionOptions, JSONXActions, Nullable } from '@univerjs/core';
import type {
DocumentDataModel,
EventState,
ICommandInfo,
IDocDrawingBase,
IExecutionOptions,
JSONXActions,
Nullable,
} from '@univerjs/core';
import type { IRichTextEditingMutationParams } from '@univerjs/docs';
import type { DocumentSkeleton, IDocumentLayoutInvalidation, IDocumentLayoutPageRange, IDocumentLayoutProgress, IDocumentLayoutProtectedRange, IDocumentSkeletonPage, IRenderContext, IRenderModule, IWheelEvent } from '@univerjs/engine-render';
import { DocumentFlavor, ICommandService, ILogService, Inject, isInternalEditorID, IUniverInstanceService, JSON1, PositionedObjectLayoutType, RxDisposable, TextX, TextXActionType, ThemeService, Tools, UniverInstanceType } from '@univerjs/core';
import { DocLayoutExecutorService, DocSelectionManagerService, DocSkeletonManagerService, RichTextEditingMutation } from '@univerjs/docs';
import { DocBackground, Documents, IRenderManagerService, Layer, PageLayoutType, ScrollBar, Viewport } from '@univerjs/engine-render';
import type {
DocumentSkeleton,
IDocumentLayoutInvalidation,
IDocumentLayoutPageRange,
IDocumentLayoutProgress,
IDocumentLayoutProtectedRange,
IDocumentSkeletonPage,
IRenderContext,
IRenderModule,
IWheelEvent,
} from '@univerjs/engine-render';
import {
CustomDecorationType,
CustomRangeType,
DocumentFlavor,
ICommandService,
ILogService,
Inject,
isInternalEditorID,
IUniverInstanceService,
JSON1,
PositionedObjectLayoutType,
RxDisposable,
TextX,
TextXActionType,
ThemeService,
Tools,
UniverInstanceType,
} from '@univerjs/core';
import {
DocLayoutExecutorService,
DocSelectionManagerService,
DocSkeletonManagerService,
RichTextEditingMutation,
} from '@univerjs/docs';
import {
DocBackground,
Documents,
IRenderManagerService,
Layer,
PageLayoutType,
ScrollBar,
Viewport,
} from '@univerjs/engine-render';
import { combineLatest, fromEvent, merge, take, takeUntil } from 'rxjs';
import { DOCS_COMPONENT_BACKGROUND_LAYER_INDEX, DOCS_COMPONENT_DEFAULT_Z_INDEX, DOCS_COMPONENT_HEADER_LAYER_INDEX, DOCS_COMPONENT_MAIN_LAYER_INDEX, DOCS_VIEW_KEY, VIEWPORT_KEY } from '../../basics/docs-view-key';
import {
DOCS_COMPONENT_BACKGROUND_LAYER_INDEX,
DOCS_COMPONENT_DEFAULT_Z_INDEX,
DOCS_COMPONENT_HEADER_LAYER_INDEX,
DOCS_COMPONENT_MAIN_LAYER_INDEX,
DOCS_VIEW_KEY,
VIEWPORT_KEY,
} from '../../basics/docs-view-key';
import { DocLayoutCoordinatorService } from '../../services/doc-layout-coordinator.service';
import { DocLayoutInteractionService } from '../../services/doc-layout-interaction.service';
import { DocPageLayoutService } from '../../services/doc-page-layout.service';
@@ -68,6 +123,70 @@ function isPlainTextXRetain(action: object & { t: unknown }): boolean {
!('coverType' in action);
}
function isHyperlinkCustomRange(value: unknown): boolean {
return typeof value === 'object' && value != null &&
'rangeType' in value && value.rangeType === CustomRangeType.HYPERLINK;
}
function isLayoutOnlyCustomDecoration(value: unknown): boolean {
return typeof value === 'object' && value != null && 'type' in value &&
(value.type === CustomDecorationType.COMMENT || value.type === CustomDecorationType.DELETED);
}
function getLayoutMetadataCount(value: Record<string, unknown>): number | null {
const ranges = value.customRanges;
const decorations = value.customDecorations;
if (
(ranges != null && (!Array.isArray(ranges) || !ranges.every(isHyperlinkCustomRange))) ||
(decorations != null && (!Array.isArray(decorations) || !decorations.every(isLayoutOnlyCustomDecoration)))
) {
return null;
}
return (Array.isArray(ranges) ? ranges.length : 0) +
(Array.isArray(decorations) ? decorations.length : 0);
}
function omitLayoutMetadata(value: Record<string, unknown>): Record<string, unknown> {
return Object.fromEntries(Object.entries(value).filter(([key]) =>
key !== 'customRanges' && key !== 'customDecorations'
));
}
function isLayoutMetadataOnlyMutation(actions: JSONXActions, segmentId?: string): boolean {
const textActions = getBodyTextXActions(actions, segmentId);
return textActions != null && textActions.length > 0 && textActions.every((action) => {
if (typeof action !== 'object' || action == null || !('t' in action) || action.t !== TextXActionType.RETAIN) {
return false;
}
if (isPlainTextXRetain(action)) {
return true;
}
if (!('body' in action) || typeof action.body !== 'object' || action.body == null) {
return false;
}
const body = action.body as Record<string, unknown>;
const bodyMetadataCount = getLayoutMetadataCount(body);
if (
body.dataStream === '' &&
bodyMetadataCount != null && bodyMetadataCount > 0 &&
Object.keys(body).every((key) =>
key === 'dataStream' || key === 'customRanges' || key === 'customDecorations'
)
) {
return true;
}
if (!('oldBody' in action) || typeof action.oldBody !== 'object' || action.oldBody == null) {
return false;
}
const oldBody = action.oldBody as Record<string, unknown>;
const oldBodyMetadataCount = getLayoutMetadataCount(oldBody);
return body.dataStream === '' && oldBody.dataStream === '' &&
bodyMetadataCount != null && oldBodyMetadataCount != null &&
bodyMetadataCount + oldBodyMetadataCount > 0 &&
Tools.diffValue(omitLayoutMetadata(body), omitLayoutMetadata(oldBody));
});
}
function getBodyMutationInvalidation(
actions: JSONXActions,
segmentId?: string
@@ -217,6 +336,9 @@ interface IDocLayoutWorkerEditBatch {
}
interface IDocLayoutScheduleOptions {
deferForeground?: boolean;
reuseMainBaseline?: boolean;
allowMetadataOnlyStructuralTailReuse?: boolean;
reason: 'initial' | 'edit';
anchor?: number;
priorityAnchor?: number;
@@ -376,6 +498,8 @@ export class DocRenderController extends RxDisposable implements IRenderModule {
private _workerPresentationResumeTimer: ReturnType<typeof setTimeout> | null = null;
private _pendingWorkerHandoff: IDocLayoutWorkerHandoff | null = null;
private _pendingWorkerEditBatch: IDocLayoutWorkerEditBatch | null = null;
private _latestLayoutRestart: (() => void) | null = null;
private _pendingImeLayoutRestart: (() => void) | null = null;
private _isImeComposing = false;
private _recoveryViewportAnchor: IDocLayoutViewportAnchor | null = null;
private _pendingMaterializedPageRange: IDocumentLayoutPageRange | null = null;
@@ -450,7 +574,8 @@ export class DocRenderController extends RxDisposable implements IRenderModule {
invalidation?: IDocumentLayoutInvalidation,
priorityAnchor?: number,
refreshMainSelection = true,
preserveInactiveViewportAnchor = false
preserveInactiveViewportAnchor = false,
deferForeground = false
) {
const docSkeletonManagerService = this._renderManagerService.getRenderUnitById(unitId)?.with(DocSkeletonManagerService);
if (!docSkeletonManagerService) {
@@ -476,7 +601,13 @@ export class DocRenderController extends RxDisposable implements IRenderModule {
const hasExplicitEditAnchor = anchor != null || priorityAnchor != null || invalidation != null;
const layoutOptions: IDocLayoutScheduleOptions = !skeleton.hasCompleteLayout() && !hasExplicitEditAnchor
? { reason: 'initial' }
: { reason: 'edit', anchor: anchor ?? 0, priorityAnchor, invalidation };
: {
reason: 'edit',
anchor: anchor ?? 0,
priorityAnchor,
invalidation,
...(deferForeground ? { deferForeground: true } : {}),
};
this._scheduleLayout(
unitId,
skeleton,
@@ -498,6 +629,8 @@ export class DocRenderController extends RxDisposable implements IRenderModule {
}
this._pendingWorkerHandoff = null;
this._pendingWorkerEditBatch = null;
this._latestLayoutRestart = null;
this._pendingImeLayoutRestart = null;
this._pendingMaterializedPageRange = null;
this._recoveryViewportAnchor = null;
super.dispose();
@@ -525,6 +658,19 @@ export class DocRenderController extends RxDisposable implements IRenderModule {
refreshMainSelection = true,
preserveInactiveViewportAnchor = false
) {
this._latestLayoutRestart = () => this._scheduleLayout(
unitId,
skeleton,
options,
refreshMainSelection,
preserveInactiveViewportAnchor
);
if (this._isImeComposing) {
// A mutation scheduled during composition already supersedes the
// generation cancelled by compositionstart. Let that newer request
// continue instead of replaying the stale pre-composition request.
this._pendingImeLayoutRestart = null;
}
const layoutRequestId = ++this._layoutRequestId;
this._cancelWorkerHandoff();
const isInitialLayout = options.reason === 'initial';
@@ -544,6 +690,13 @@ export class DocRenderController extends RxDisposable implements IRenderModule {
undefined,
false
);
if (options.reuseMainBaseline) {
// Offset-preserving render metadata does not change Worker geometry.
// Keep any older pending batch intact, but do not create a false
// background-layout task for this Main-only publication.
this._layoutCoordinator.schedule(skeleton, options, mainThreadCallbacks);
return;
}
if (this._docLayoutExecutorService.getExecutor() == null) {
this._pendingWorkerEditBatch = null;
this._layoutCoordinator.schedule(skeleton, options, mainThreadCallbacks);
@@ -587,7 +740,10 @@ export class DocRenderController extends RxDisposable implements IRenderModule {
mainThreadCallbacks: DocLayoutCoordinatorCallbacks,
preserveInactiveViewportAnchor: boolean
): void {
this._layoutCoordinator.schedule(skeleton, options, {
// Initial layout has no mutation anchor. Give its foreground window an
// explicit viewport anchor so it can hand off before Main finishes the
// whole document; only a stable physical page can publish this anchor.
this._layoutCoordinator.schedule(skeleton, { ...options, priorityAnchor: options.priorityAnchor ?? 0 }, {
onProgress: mainThreadCallbacks.onProgress,
onForegroundReady: (progress) => {
if (progress.complete) {
@@ -661,7 +817,7 @@ export class DocRenderController extends RxDisposable implements IRenderModule {
this._layoutCoordinator.schedule(skeleton, {
...options,
anchor: interactionAnchor,
foregroundEndOffset,
...(foregroundEndOffset == null ? {} : { foregroundEndOffset }),
preserveInteractionWindow: interactionAnchor != null && interactionAnchor !== options.anchor,
// The authoritative Worker recomputes the suffix after the protected
// interaction window. Deep-cloning and shifting the complete previous
@@ -707,7 +863,7 @@ export class DocRenderController extends RxDisposable implements IRenderModule {
return;
}
// RichTextEditingMutation advances the logical range in a microtask. The
// The mutation defers visual selection refresh to a microtask. The
// foreground window can replace its adjacent page afterwards, so rebuild
// the caret before handing the remaining suffix to the Worker.
this._refreshPagePositionAndSelection(this._getActiveEditingRange(unitId) != null);
@@ -840,7 +996,9 @@ export class DocRenderController extends RxDisposable implements IRenderModule {
);
} else if (isInitialLayout) {
this._refreshPagePosition();
} else if (progress.didPublishAnchor && refreshIncompleteAnchorSelection) {
} else if (progress.didPublishAnchor && (
refreshIncompleteAnchorSelection || this._docSelectionRenderService.hasPendingSelection
)) {
// The foreground pass replaces edited line and glyph objects. Rebuild
// the caret from stable document offsets without moving the viewport.
this._textSelectionManagerService.refreshSelection(
@@ -886,7 +1044,7 @@ export class DocRenderController extends RxDisposable implements IRenderModule {
didReplaceProtectedPages: boolean
): void {
this._docLayoutExecutorService.completeRecovery(unitId);
if (refreshCompleteSelection || didReplaceProtectedPages) {
if (refreshCompleteSelection || didReplaceProtectedPages || this._docSelectionRenderService.hasPendingSelection) {
const isEditing = preserveEditingSelection ||
didReplaceProtectedPages ||
this._getActiveEditingRange(unitId) != null;
@@ -1066,6 +1224,12 @@ export class DocRenderController extends RxDisposable implements IRenderModule {
clearTimeout(this._workerHandoffTimer);
this._workerHandoffTimer = null;
}
if (
this._pendingWorkerHandoff == null &&
this._layoutCoordinator.hasScheduledLayout()
) {
this._pendingImeLayoutRestart = this._latestLayoutRestart;
}
this._layoutCoordinator.cancel();
});
this._docSelectionRenderService.onCompositionend$
@@ -1076,6 +1240,12 @@ export class DocRenderController extends RxDisposable implements IRenderModule {
}
this._isImeComposing = false;
const restartLayout = this._pendingImeLayoutRestart;
this._pendingImeLayoutRestart = null;
if (restartLayout != null) {
restartLayout();
return;
}
this._startWorkerHandoffTimer();
});
}
@@ -1179,10 +1349,23 @@ export class DocRenderController extends RxDisposable implements IRenderModule {
return undefined;
}
let endPageIndex = publishedEndPageIndex;
const maximumProtectedEndPageIndex = Math.min(
pages.length - 1,
startPageIndex + MATERIALIZED_PAGE_WINDOW_SIZE - 1
);
while (endPageIndex < maximumProtectedEndPageIndex) {
const nextPage = pages[endPageIndex + 1];
if (nextPage == null || nextPage.isLayoutPlaceholder || nextPage.isMaterializationPlaceholder) {
break;
}
endPageIndex++;
}
return {
mode: 'paginated',
startPageIndex,
endPageIndex: publishedEndPageIndex,
endPageIndex,
};
}
@@ -1561,8 +1744,8 @@ export class DocRenderController extends RxDisposable implements IRenderModule {
const activeRange = this._getActiveEditingRange(unitId);
const isRemoteMutation = params.isSync === true || executionOptions?.fromCollab === true;
// Local mutations carry the post-edit selection. Prefer it over the
// selection manager because the latter is advanced in a microtask after
// this command callback. A collaboration payload can still carry the
// selection manager to keep the requested edit anchor explicit.
// A collaboration payload can still carry the
// remote author's ranges, so execution options—not range presence—decide
// whether the transformed local caret remains authoritative.
const mutationActiveRange = isRemoteMutation
@@ -1571,9 +1754,9 @@ export class DocRenderController extends RxDisposable implements IRenderModule {
(bodyRanges.length === 1 ? bodyRanges[0] : undefined);
const priorityAnchor = mutationActiveRange?.endOffset ?? activeRange?.endOffset;
// RichTextEditingMutation preserves the original Main input
// contract by applying its local post-edit range in a microtask.
// Rebuilding the old active range during the synchronous anchor
// pass makes the caret visibly jump backward before that microtask.
// contract by refreshing its local post-edit range in a microtask,
// once the synchronous layout prefix has finished. Do not publish
// intermediate selection geometry during that prefix.
// Remote/direct mutations do not schedule this local range update
// and therefore still need the render controller refresh.
const hasPendingLocalSelectionUpdate = doesMutationScheduleLocalSelectionUpdate(
@@ -1581,13 +1764,51 @@ export class DocRenderController extends RxDisposable implements IRenderModule {
isRemoteMutation
);
if (invalidation != null && isLayoutMetadataOnlyMutation(params.actions, params.segmentId)) {
const manager = this._renderManagerService.getRenderUnitById(unitId)?.with(DocSkeletonManagerService);
const skeleton = manager?.getSkeleton();
const pages = skeleton?.getSkeletonData()?.pages;
const affectedOffsets = [invalidation.oldStart, Math.max(invalidation.oldStart, invalidation.oldEnd - 1)];
const affectedPagesAreMaterialized = skeleton != null && pages != null && affectedOffsets.every((offset) => {
const pageIndex = skeleton.findNodePositionByCharIndex(offset)?.page ?? -1;
const page = pages[pageIndex];
return page != null && !page.isLayoutPlaceholder && !page.isMaterializationPlaceholder;
});
if (
manager?.supportsIncrementalLayout() &&
this._context.unit.documentStyle.documentFlavor === DocumentFlavor.TRADITIONAL &&
!this._context.unit.getSnapshot().disabled &&
skeleton?.hasCompleteLayout() &&
skeleton.getLayoutProgress() == null &&
pages != null && pages.length > 0 &&
pages.every((page) => !page.isLayoutPlaceholder) &&
affectedPagesAreMaterialized
) {
// Initial pagination may have completed entirely on Main, before
// a Worker mount exists. Reuse that complete baseline for range
// metadata instead of computing the first Worker layout from zero.
// The Worker needs no handoff because the mutation preserves
// character offsets and layout geometry.
this._scheduleLayout(unitId, skeleton, {
reason: 'edit',
anchor,
priorityAnchor,
invalidation,
reuseMainBaseline: true,
allowMetadataOnlyStructuralTailReuse: true,
}, !hasPendingLocalSelectionUpdate, isRemoteMutation);
return;
}
}
this.reRender(
unitId,
anchor,
invalidation,
priorityAnchor,
!hasPendingLocalSelectionUpdate,
isRemoteMutation
isRemoteMutation,
Array.isArray(params.actions) && params.actions.length > 0 && bodyRanges.length === 0 && invalidation == null
);
}));
}
@@ -14,83 +14,208 @@
* limitations under the License.
*/
import type { IDocumentData, UnitModel } from '@univerjs/core';
import {
DisposableCollection,
DOC_RANGE_TYPE,
ILogService,
IUniverInstanceService,
LogLevel,
Univer,
UniverInstanceType,
} from '@univerjs/core';
// @vitest-environment jsdom
import type { DocumentDataModel, IDocumentData } from '@univerjs/core';
import type { IPointerEvent, RenderUnit } from '@univerjs/engine-render';
import { BooleanNumber, DocumentFlavor, ICommandService, IUniverInstanceService, Univer, UniverInstanceType } from '@univerjs/core';
import { FUniver } from '@univerjs/core/facade';
import { UniverDocsPlugin } from '@univerjs/docs';
import { IRenderManagerService, RenderManagerService } from '@univerjs/engine-render';
import { BehaviorSubject } from 'rxjs';
import { DocLayoutExecutorService, DocSelectionManagerService, DocSkeletonManagerService, DocStateEmitService, SetTextSelectionsOperation } from '@univerjs/docs';
import { CanvasColorService, ICanvasColorService, IRenderManagerService, RenderManagerService } from '@univerjs/engine-render';
import { ILayoutService } from '@univerjs/ui';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { DocBackScrollRenderController } from '../../controllers/render-controllers/back-scroll.render-controller';
import { DocSelectionRenderController } from '../../controllers/render-controllers/doc-selection-render.controller';
import { DocRenderController } from '../../controllers/render-controllers/doc.render-controller';
import { DocLayoutInteractionService } from '../../services/doc-layout-interaction.service';
import { DocMenuStyleService } from '../../services/doc-menu-style.service';
import { DocPageLayoutService } from '../../services/doc-page-layout.service';
import { DocViewScaleService } from '../../services/doc-view-scale';
import { EditorService, IEditorService } from '../../services/editor/editor-manager.service';
import { DocSelectionRenderService } from '../../services/selection/doc-selection-render.service';
import '@univerjs/docs/facade';
import '@univerjs/docs-ui/facade';
import '../f-document';
function createEditor() {
const univer = new Univer();
const injector = univer.__getInjector();
const root = document.createElement('div');
document.body.appendChild(root);
injector.add([ILayoutService, { useValue: {
rootContainerElement: root,
registerContainerElement: () => ({ dispose() {} }),
} as unknown as ILayoutService }]);
injector.add([IRenderManagerService, { useClass: RenderManagerService }]);
injector.add([ICanvasColorService, { useClass: CanvasColorService }]);
injector.add([DocLayoutExecutorService]);
injector.add([DocSelectionManagerService]);
injector.add([DocStateEmitService]);
injector.add([DocMenuStyleService]);
injector.add([IEditorService, { useClass: EditorService }]);
injector.get(ICommandService).registerCommand(SetTextSelectionsOperation);
const dataStream = `${'This paragraph contains enough words to occupy several physical pages.\r'.repeat(100)}\n`;
const model = univer.createUnit<IDocumentData, DocumentDataModel>(UniverInstanceType.UNIVER_DOC, {
id: 'facade-selection',
body: {
dataStream,
paragraphs: [...dataStream.matchAll(/\r/g)].map((match, index) => ({ startIndex: match.index!, paragraphId: `paragraph-${index}` })),
sectionBreaks: [{ startIndex: dataStream.length - 1, sectionId: 'body' }],
},
documentStyle: {
documentFlavor: DocumentFlavor.TRADITIONAL,
autoHyphenation: BooleanNumber.FALSE,
pageSize: { width: 300, height: 400 },
marginTop: 20,
marginBottom: 20,
marginLeft: 20,
marginRight: 20,
},
});
const unitId = model.getUnitId();
const instances = injector.get(IUniverInstanceService);
instances.setCurrentUnitForType(unitId);
instances.focusUnit(unitId);
const render = injector.get(IRenderManagerService).createRender(unitId) as RenderUnit;
render.engine.resizeBySize(800, 600);
render.deactivate();
render.addRenderDependencies([
[DocSkeletonManagerService],
[DocSelectionRenderService],
[DocViewScaleService],
[DocPageLayoutService],
[DocLayoutInteractionService],
[DocRenderController],
[DocBackScrollRenderController],
[DocSelectionRenderController],
]);
const skeleton = render.with(DocSkeletonManagerService).getSkeleton();
const pages = skeleton.getSkeletonData()!.pages;
const page = pages[6];
expect(page).toBeDefined();
return {
doc: FUniver.newAPI(injector).getActiveDocument()!,
model,
render,
skeleton,
selections: injector.get(DocSelectionManagerService),
selection: render.with(DocSelectionRenderService),
offset: page.st + 3,
makeOffscreen(): void {
pages[6] = { ...page, isMaterializationPlaceholder: true, sections: [], skeTables: new Map() };
},
materialize(): void {
pages[6] = page;
},
dispose(): void {
univer.dispose();
root.remove();
},
};
}
describe('docs-ui document facade', () => {
let univer: Univer;
let univerAPI: FUniver;
let removeAllRanges: ReturnType<typeof vi.fn>;
let addDocRanges: ReturnType<typeof vi.fn>;
beforeEach(() => {
univer = new Univer();
const injector = univer.__getInjector();
injector.add([IRenderManagerService, { useClass: RenderManagerService }]);
injector.get(ILogService).setLogLevel(LogLevel.SILENT);
univer.registerPlugin(UniverDocsPlugin);
const doc = univer.createUnit<IDocumentData, UnitModel<IDocumentData>>(UniverInstanceType.UNIVER_DOC, {
id: 'doc-1',
body: {
dataStream: 'Hello Univer\r\n',
paragraphs: [{ startIndex: 12, paragraphId: 'paragraph-1' }],
},
});
removeAllRanges = vi.fn();
addDocRanges = vi.fn();
injector.get(IUniverInstanceService).focusUnit(doc.getUnitId());
injector.get(IRenderManagerService).addRender(doc.getUnitId(), {
unitId: doc.getUnitId(),
type: UniverInstanceType.UNIVER_DOC,
engine: new DisposableCollection() as never,
scene: new DisposableCollection() as never,
mainComponent: null,
components: new Map(),
isMainScene: true,
activated$: new BehaviorSubject(true),
with: <T>() => ({
removeAllRanges,
addDocRanges,
}) as T,
activate: () => undefined,
deactivate: () => undefined,
isDisposed: () => false,
});
univerAPI = FUniver.newAPI(injector);
vi.useFakeTimers();
const context = 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(context as never);
});
afterEach(() => {
univer.dispose();
vi.restoreAllMocks();
vi.useRealTimers();
});
it('replaces the rendered text selection with the requested document offsets', () => {
univerAPI.getActiveDocument()!.setSelection(2, 7);
it('replaces the text selection with the requested document offsets', () => {
const editor = createEditor();
try {
editor.doc.setSelection(2, 7);
expect(editor.selections.getActiveTextRange()).toMatchObject({ startOffset: 2, endOffset: 7 });
} finally {
editor.dispose();
}
});
expect(removeAllRanges).toHaveBeenCalledOnce();
expect(addDocRanges).toHaveBeenCalledWith([
{
startOffset: 2,
endOffset: 7,
rangeType: DOC_RANGE_TYPE.TEXT,
},
], true);
it('explicitly scrolls an already-materialized offscreen SDK selection into view', () => {
const editor = createEditor();
try {
const scrollToRange = vi.spyOn(editor.render.with(DocBackScrollRenderController), 'scrollToRange');
editor.doc.setSelection(editor.offset, editor.offset);
expect(scrollToRange).toHaveBeenCalledWith({
startOffset: editor.offset,
endOffset: editor.offset,
collapsed: true,
});
} finally {
editor.dispose();
}
});
it.each([0, 3])('resolves an offscreen SDK selection of length %i after page materialization', async (length) => {
const editor = createEditor();
try {
editor.doc.setSelection(2, 2);
editor.makeOffscreen();
editor.doc.setSelection(editor.offset, editor.offset + length);
expect(editor.selections.getActiveTextRange()?.startOffset).not.toBe(2);
editor.materialize();
await vi.advanceTimersByTimeAsync(40);
expect(editor.selections.getActiveTextRange()).toMatchObject({ startOffset: editor.offset, endOffset: editor.offset + length });
expect(editor.selection.getActiveTextRange()?.startOffset).toBe(editor.offset);
} finally {
editor.dispose();
}
});
it('resolves an SDK selection whose page has not been published yet', async () => {
const editor = createEditor();
try {
editor.doc.setSelection(2, 2);
const pages = editor.skeleton.getSkeletonData()!.pages;
const unpublished = pages.splice(2);
editor.skeleton.beginExternalLayout({ reason: 'initial' });
editor.doc.setSelection(editor.offset, editor.offset);
expect(editor.selections.getActiveTextRange()?.startOffset).not.toBe(editor.offset);
pages.push(...unpublished);
await vi.advanceTimersByTimeAsync(40);
expect(editor.selections.getActiveTextRange()).toMatchObject({
startOffset: editor.offset,
endOffset: editor.offset,
});
} finally {
editor.dispose();
}
});
it.each(['selection', 'pointer', 'text-change'])('does not restore an old SDK target after a newer %s', async (action) => {
const editor = createEditor();
try {
editor.makeOffscreen();
editor.doc.setSelection(editor.offset, editor.offset);
if (action === 'selection') {
editor.doc.setSelection(4, 4);
} else if (action === 'pointer') {
editor.render.scene.onPointerDown$.emitEvent({ offsetX: 30, offsetY: 30, button: 0 } as IPointerEvent);
} else {
editor.model.getBody()!.dataStream = `X${editor.model.getBody()!.dataStream}`;
}
editor.materialize();
await vi.advanceTimersByTimeAsync(40);
expect(editor.selections.getActiveTextRange()?.startOffset).not.toBe(editor.offset);
if (action === 'selection') {
expect(editor.selections.getActiveTextRange()?.startOffset).toBe(4);
}
} finally {
editor.dispose();
}
});
});
+62 -14
View File
@@ -15,7 +15,8 @@
*/
import { DOC_RANGE_TYPE } from '@univerjs/core';
import { DocSelectionRenderService } from '@univerjs/docs-ui';
import { DocSkeletonManagerService } from '@univerjs/docs';
import { DocBackScrollRenderController, DocSelectionRenderService } from '@univerjs/docs-ui';
import { FDocument } from '@univerjs/docs/facade';
import { IRenderManagerService } from '@univerjs/engine-render';
@@ -26,6 +27,8 @@ interface IFDocumentUIMixin {
export class FDocumentUIMixin extends FDocument implements IFDocumentUIMixin {
/**
* Sets the selection to a specified text range in the document.
* A computed offscreen target is selected after its render data is loaded.
* A newer pointer/selection action or changed text supersedes that request.
* @param startOffset - The starting offset of the selection in the document.
* @param endOffset - The ending offset of the selection in the document.
* @example
@@ -35,20 +38,65 @@ export class FDocumentUIMixin extends FDocument implements IFDocumentUIMixin {
* ```
*/
override setSelection(startOffset: number, endOffset: number): void {
// TODO: @jocs...
const renderManagerService = this._injector.get(IRenderManagerService);
const docSelectionRenderService = renderManagerService.getRenderUnitById(this.getId())?.with(DocSelectionRenderService);
docSelectionRenderService?.removeAllRanges();
docSelectionRenderService?.addDocRanges(
[
{
startOffset,
endOffset,
rangeType: DOC_RANGE_TYPE.TEXT,
},
],
true
);
const render = renderManagerService.getRenderUnitById(this.getId());
if (render == null) {
return;
}
const docSelectionRenderService = render.with(DocSelectionRenderService);
const skeleton = render.with(DocSkeletonManagerService).getSkeleton();
const pageIndex = skeleton.findBodyPageIndexByCharIndex(startOffset);
const page = skeleton.getSkeletonData()?.pages[pageIndex];
const bodyLength = this.getDocumentDataModel().getBody()?.dataStream.length ?? 0;
const unresolvedBodyPage = pageIndex < 0 && startOffset >= 0 && startOffset < bodyLength;
const segmentId = docSelectionRenderService.getSegment();
const renderInjector = render.getInjector?.();
const backScrollController = renderInjector?.has(DocBackScrollRenderController)
? render.with(DocBackScrollRenderController)
: null;
const range = {
startOffset,
endOffset,
collapsed: startOffset === endOffset,
};
const applySelection = (): void => {
docSelectionRenderService.removeAllRanges();
docSelectionRenderService.addDocRanges(
[
{
startOffset,
endOffset,
rangeType: DOC_RANGE_TYPE.TEXT,
},
],
true
);
};
if (segmentId === '' && backScrollController != null &&
(unresolvedBodyPage || page?.isMaterializationPlaceholder || !backScrollController.isViewportReady())) {
// The page or viewport is not ready to own an editable caret. Hide
// and blur the old visual range while the bounded navigation request
// waits; a real pointer/scroll/new selection still cancels it.
const dataStream = this.getDocumentDataModel().getBody()?.dataStream;
backScrollController.scrollToRange(range, () => {
if (docSelectionRenderService.getSegment() !== segmentId ||
this.getDocumentDataModel().getBody()?.dataStream !== dataStream) {
return;
}
applySelection();
});
docSelectionRenderService.removeAllRanges();
docSelectionRenderService.addDocRanges([], true, { shouldFocus: false });
docSelectionRenderService.blur();
return;
}
applySelection();
if (segmentId === '' && backScrollController != null) {
// SDK selections are imperative navigation requests. Do not rely on
// the deferred selection event to reveal an already-materialized
// offscreen page because a prior user scroll may suppress it.
backScrollController.scrollToRange(range);
}
}
}
@@ -0,0 +1,26 @@
/**
* 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 { describe, expect, it } from 'vitest';
import enUS from '../en-US';
import zhCN from '../zh-CN';
describe('Page Setup dialog title regression', () => {
it('uses the Page Setup product name in English and Simplified Chinese', () => {
expect(enUS['docs-ui']['page-settings']['document-setting']).toBe('Page Setup');
expect(zhCN['docs-ui']['page-settings']['document-setting']).toBe('页面设置');
});
});
+1
View File
@@ -19,6 +19,7 @@ import type enUS from './en-US';
const locale: typeof enUS = {
'docs-ui': {
layout: {
progress: 'جارٍ تنسيق المستند…',
recovering: 'جارٍ استعادة تخطيط المستند…',
},
toolbar: {
+1
View File
@@ -19,6 +19,7 @@ import type enUS from './en-US';
const locale: typeof enUS = {
'docs-ui': {
layout: {
progress: 'S’està maquetant el document…',
recovering: 'S’està restaurant el disseny del document…',
},
toolbar: {
+1
View File
@@ -19,6 +19,7 @@ import type enUS from './en-US';
const locale: typeof enUS = {
'docs-ui': {
layout: {
progress: 'Dokument wird formatiert…',
recovering: 'Dokumentlayout wird wiederhergestellt…',
},
toolbar: {
+2 -1
View File
@@ -17,6 +17,7 @@
const locale = {
'docs-ui': {
layout: {
progress: 'Laying out document…',
recovering: 'Restoring document layout…',
},
toolbar: {
@@ -190,7 +191,7 @@ const locale = {
noBackground: 'No background',
},
'page-settings': {
'document-setting': 'Document Setting',
'document-setting': 'Page Setup',
'modern-width': 'Content width',
'modern-width-narrow': 'Narrow',
'modern-width-medium': 'Medium',
+1
View File
@@ -19,6 +19,7 @@ import type enUS from './en-US';
const locale: typeof enUS = {
'docs-ui': {
layout: {
progress: 'Maquetando el documento…',
recovering: 'Restaurando el diseño del documento…',
},
toolbar: {
+1
View File
@@ -19,6 +19,7 @@ import type enUS from './en-US';
const locale: typeof enUS = {
'docs-ui': {
layout: {
progress: 'در حال صفحه‌آرایی سند…',
recovering: 'در حال بازیابی صفحه‌آرایی سند…',
},
toolbar: {
+1
View File
@@ -19,6 +19,7 @@ import type enUS from './en-US';
const locale: typeof enUS = {
'docs-ui': {
layout: {
progress: 'Mise en page du document…',
recovering: 'Restauration de la mise en page du document…',
},
toolbar: {
+1
View File
@@ -19,6 +19,7 @@ import type enUS from './en-US';
const locale: typeof enUS = {
'docs-ui': {
layout: {
progress: 'Menata dokumen…',
recovering: 'Memulihkan tata letak dokumen…',
},
toolbar: {
+1
View File
@@ -19,6 +19,7 @@ import type enUS from './en-US';
const locale: typeof enUS = {
'docs-ui': {
layout: {
progress: 'Impaginazione del documento…',
recovering: 'Ripristino del layout del documento…',
},
toolbar: {
+1
View File
@@ -19,6 +19,7 @@ import type enUS from './en-US';
const locale: typeof enUS = {
'docs-ui': {
layout: {
progress: 'ドキュメントをレイアウトしています…',
recovering: 'ドキュメントのレイアウトを復元しています…',
},
toolbar: {
+1
View File
@@ -19,6 +19,7 @@ import type enUS from './en-US';
const locale: typeof enUS = {
'docs-ui': {
layout: {
progress: '문서 레이아웃을 처리하는 중…',
recovering: '문서 레이아웃을 복구하는 중…',
},
toolbar: {
+1
View File
@@ -19,6 +19,7 @@ import type enUS from './en-US';
const locale: typeof enUS = {
'docs-ui': {
layout: {
progress: 'Układanie dokumentu…',
recovering: 'Przywracanie układu dokumentu…',
},
toolbar: {
+1
View File
@@ -19,6 +19,7 @@ import type enUS from './en-US';
const locale: typeof enUS = {
'docs-ui': {
layout: {
progress: 'Formatando o documento…',
recovering: 'Restaurando o layout do documento…',
},
toolbar: {
+1
View File
@@ -19,6 +19,7 @@ import type enUS from './en-US';
const locale: typeof enUS = {
'docs-ui': {
layout: {
progress: 'Выполняется разметка документа…',
recovering: 'Восстановление макета документа…',
},
toolbar: {
+1
View File
@@ -19,6 +19,7 @@ import type enUS from './en-US';
const locale: typeof enUS = {
'docs-ui': {
layout: {
progress: 'Rozkladá sa dokument…',
recovering: 'Obnovuje sa rozloženie dokumentu…',
},
toolbar: {
+1
View File
@@ -19,6 +19,7 @@ import type enUS from './en-US';
const locale: typeof enUS = {
'docs-ui': {
layout: {
progress: 'Đang dàn trang tài liệu…',
recovering: 'Đang khôi phục bố cục tài liệu…',
},
toolbar: {
+2 -1
View File
@@ -19,6 +19,7 @@ import type enUS from './en-US';
const locale: typeof enUS = {
'docs-ui': {
layout: {
progress: '正在排版文档…',
recovering: '正在恢复文档排版…',
},
toolbar: {
@@ -192,7 +193,7 @@ const locale: typeof enUS = {
noBackground: '无背景色',
},
'page-settings': {
'document-setting': '文档设置',
'document-setting': '页面设置',
'modern-width': '内容宽度',
'modern-width-narrow': '窄',
'modern-width-medium': '适中',
+1
View File
@@ -19,6 +19,7 @@ import type enUS from './en-US';
const locale: typeof enUS = {
'docs-ui': {
layout: {
progress: '正在排版文件…',
recovering: '正在恢復文件排版…',
},
toolbar: {
+1
View File
@@ -19,6 +19,7 @@ import type enUS from './en-US';
const locale: typeof enUS = {
'docs-ui': {
layout: {
progress: '正在排版文件…',
recovering: '正在復原文件排版…',
},
toolbar: {
+6 -2
View File
@@ -161,6 +161,7 @@ import { DocContextMenuRenderController } from './controllers/render-controllers
import { DocEditorBridgeController } from './controllers/render-controllers/doc-editor-bridge.controller';
import { DocIMEInputController } from './controllers/render-controllers/doc-ime-input.controller';
import { DocInputController } from './controllers/render-controllers/doc-input.controller';
import { DocLayoutProgressRenderController } from './controllers/render-controllers/doc-layout-progress.render-controller';
import { DocLayoutRecoveryRenderController } from './controllers/render-controllers/doc-layout-recovery.render-controller';
import {
DocParagraphPlaceholderRenderController,
@@ -181,6 +182,7 @@ import { DocEventManagerService } from './services/doc-event-manager.service';
import { DocIMEInputManagerService } from './services/doc-ime-input-manager.service';
import { DocIMEStateChangeInterceptorService } from './services/doc-ime-state-change-interceptor.service';
import { DocLayoutInteractionService } from './services/doc-layout-interaction.service';
import { DocLayoutProgressService } from './services/doc-layout-progress.service';
import { DocMenuStyleService, SetDocInputStyleCommand } from './services/doc-menu-style.service';
import { DocPageLayoutService } from './services/doc-page-layout.service';
import { DocParagraphMenuService } from './services/doc-paragraph-menu.service';
@@ -454,6 +456,7 @@ export class UniverDocsUIPlugin extends Plugin {
[IDocClipboardPasteAdapterService, { useClass: DocClipboardPasteAdapterService }],
[DocHtmlExportService],
[DocCanvasPopManagerService],
[DocLayoutProgressService],
[DocsRenderService],
[IDocStateChangeInterceptorService, { useClass: DocIMEStateChangeInterceptorService }],
[DocAutoFormatService],
@@ -488,8 +491,11 @@ export class UniverDocsUIPlugin extends Plugin {
[DocLayoutInteractionService],
[DocCanvasPopupLayoutInteractionController],
[DocIMEInputManagerService],
[DocLayoutProgressRenderController],
[DocRenderController],
[DocZoomRenderController],
[DocBackScrollRenderController],
[DocSelectionRenderController],
] as Dependency[]).forEach((m) => {
this._renderManagerSrv.registerRenderModule(UniverInstanceType.UNIVER_DOC, m);
});
@@ -500,8 +506,6 @@ export class UniverDocsUIPlugin extends Plugin {
[DocEventManagerService],
[DocFloatMenuService],
[DocParagraphMenuService],
[DocBackScrollRenderController],
[DocSelectionRenderController],
[DocHeaderFooterController],
[DocResizeRenderController],
[DocParagraphPlaceholderRenderController],
@@ -14,11 +14,14 @@
* limitations under the License.
*/
import { CustomRangeType, EventState, EventSubject, Injector, PresetListType } from '@univerjs/core';
import { DocSkeletonManagerService } from '@univerjs/docs';
import { setDocsTableRenderViewportProvider, TRANSFORM_CHANGE_OBSERVABLE_TYPE } from '@univerjs/engine-render';
import { afterEach, describe, expect, it } from 'vitest';
import type { DocumentDataModel, IDocumentData } from '@univerjs/core';
import type { IPointerEvent, RenderUnit } from '@univerjs/engine-render';
import { BooleanNumber, CustomRangeType, DocumentFlavor, EventState, EventSubject, Injector, PresetListType, Univer, UniverInstanceType } from '@univerjs/core';
import { DocLayoutExecutorService, DocSkeletonManagerService } from '@univerjs/docs';
import { CanvasColorService, Documents, ICanvasColorService, IRenderManagerService, RenderManagerService, setDocsTableRenderViewportProvider, TRANSFORM_CHANGE_OBSERVABLE_TYPE } from '@univerjs/engine-render';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
calcDocGlyphPosition,
DocEventManagerService,
getListMarkerFallbackBound,
getListMarkerFallbackHit,
@@ -29,6 +32,85 @@ import {
isChecklistListType,
} from '../doc-event-manager.service';
describe('custom-range hit testing with real document layout', () => {
beforeEach(() => {
const context = 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(context as never);
});
afterEach(() => vi.restoreAllMocks());
it.each([1, 3])('includes the final glyph of a %i-character link without hitting adjacent text', (length) => {
const univer = new Univer();
try {
const injector = univer.__getInjector();
injector.add([IRenderManagerService, { useClass: RenderManagerService }]);
injector.add([ICanvasColorService, { useClass: CanvasColorService }]);
injector.add([DocLayoutExecutorService]);
const model = univer.createUnit<IDocumentData, DocumentDataModel>(UniverInstanceType.UNIVER_DOC, {
id: 'custom-range-hit-test',
body: {
dataStream: 'A目CDZ\r\n',
paragraphs: [{ startIndex: 5, paragraphId: 'paragraph-1' }],
sectionBreaks: [{ startIndex: 6, sectionId: 'body' }],
customRanges: [{ startIndex: 1, endIndex: length, rangeId: 'link', rangeType: CustomRangeType.HYPERLINK }],
},
documentStyle: {
documentFlavor: DocumentFlavor.TRADITIONAL,
autoHyphenation: BooleanNumber.FALSE,
pageSize: { width: 300, height: 400 },
marginTop: 20,
marginBottom: 20,
marginLeft: 20,
marginRight: 20,
},
});
const render = injector.get(IRenderManagerService).createRender(model.getUnitId()) as RenderUnit;
render.deactivate();
render.addRenderDependencies([[DocSkeletonManagerService]]);
const skeleton = render.with(DocSkeletonManagerService).getSkeleton();
const documents = new Documents('custom-range-document', skeleton);
render.mainComponent = documents;
render.scene.addObject(documents);
render.addRenderDependencies([[DocEventManagerService]]);
const service = render.with(DocEventManagerService);
const clicks: string[] = [];
const subscription = service.clickCustomRanges$.subscribe((event) => clicks.push(event.range.rangeId));
const glyph = skeleton.findNodeByCharIndex(length)!;
const bounds = calcDocGlyphPosition(glyph, documents, skeleton)!;
expect(bounds.right - bounds.left).toBeGreaterThan(0);
const click = (x: number): void => {
const event = {
button: 0,
offsetX: x,
offsetY: (bounds.top + bounds.bottom) / 2,
target: documents,
timeStamp: 100,
} as unknown as IPointerEvent;
documents.onPointerDown$.emitEvent(event);
render.scene.onPointerUp$.emitEvent({ ...event, timeStamp: 180 });
};
click(bounds.left + (bounds.right - bounds.left) * 0.75);
expect(clicks).toEqual(['link']);
click(bounds.right + 1);
expect(clicks).toEqual(['link']);
subscription.unsubscribe();
} finally {
univer.dispose();
}
});
});
class TestDocSkeletonManagerService {
readonly dirty$ = new EventSubject();
bodyLineTop = 30;
@@ -89,6 +89,23 @@ describe('DocLayoutCoordinatorService', () => {
coordinator.dispose();
});
it('cancels the active generation when a main-thread layout step throws', () => {
const error = new Error('layout failed');
const skeleton = {
startIncrementalLayout: vi.fn(() => 1),
stepIncrementalLayout: vi.fn(() => {
throw error;
}),
cancelIncrementalLayout: vi.fn(),
} satisfies DocumentLayoutSchedulingSkeleton;
const coordinator = new DocLayoutCoordinatorService();
expect(() => coordinator.schedule(skeleton, { reason: 'edit', anchor: 1 }, { onProgress: vi.fn() })).toThrow(error);
expect(skeleton.cancelIncrementalLayout).toHaveBeenCalledWith(1);
expect(coordinator.hasScheduledLayout()).toBe(false);
coordinator.dispose();
});
it('finishes the edited anchor synchronously one atomic block at a time', () => {
const requestAnimationFrameSpy = vi.fn((callback: FrameRequestCallback) =>
window.setTimeout(() => callback(performance.now()), 0));
@@ -119,6 +136,60 @@ describe('DocLayoutCoordinatorService', () => {
coordinator.dispose();
});
it('yields a distant edited anchor to the next visual frame after four synchronous atomic blocks', () => {
const requestAnimationFrameSpy = vi.fn((callback: FrameRequestCallback) =>
window.setTimeout(() => callback(performance.now()), 0));
vi.stubGlobal('requestAnimationFrame', requestAnimationFrameSpy);
const steps = [
...Array.from({ length: 6 }, (_, index) => createProgress({
anchorReady: index === 5,
laidOutThrough: (index + 1) * 10,
})),
createProgress({ anchorReady: true, laidOutThrough: 100, complete: true }),
];
const skeleton = {
startIncrementalLayout: vi.fn(() => 1),
stepIncrementalLayout: vi.fn(() => steps.shift()!),
cancelIncrementalLayout: vi.fn(),
} satisfies DocumentLayoutSchedulingSkeleton;
const coordinator = new DocLayoutCoordinatorService();
coordinator.schedule(skeleton, { reason: 'edit', anchor: 90 }, { onProgress: vi.fn() });
expect(skeleton.stepIncrementalLayout).toHaveBeenCalledTimes(4);
expect(skeleton.stepIncrementalLayout).toHaveBeenNthCalledWith(4, 1, 0);
expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(1);
vi.runAllTimers();
expect(skeleton.stepIncrementalLayout).toHaveBeenCalledTimes(7);
expect(skeleton.stepIncrementalLayout).toHaveBeenNthCalledWith(5, 1, 8);
coordinator.dispose();
});
it('defers metadata-only edit layout until the next visual frame', () => {
const requestAnimationFrameSpy = vi.fn((callback: FrameRequestCallback) =>
window.setTimeout(() => callback(performance.now()), 0));
vi.stubGlobal('requestAnimationFrame', requestAnimationFrameSpy);
const skeleton = {
startIncrementalLayout: vi.fn(() => 1),
stepIncrementalLayout: vi.fn(() => createProgress({ complete: true, anchorReady: true })),
cancelIncrementalLayout: vi.fn(),
} satisfies DocumentLayoutSchedulingSkeleton;
const coordinator = new DocLayoutCoordinatorService();
coordinator.schedule(skeleton, {
reason: 'edit',
anchor: 0,
deferForeground: true,
}, { onProgress: vi.fn() });
expect(skeleton.stepIncrementalLayout).not.toHaveBeenCalled();
expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(1);
vi.runAllTimers();
expect(skeleton.stepIncrementalLayout).toHaveBeenCalledTimes(1);
coordinator.dispose();
});
it('lets the shell paint once before starting an initial layout', () => {
const requestAnimationFrameSpy = vi.fn((callback: FrameRequestCallback) =>
window.setTimeout(() => callback(performance.now()), 0));
@@ -138,6 +209,9 @@ describe('DocLayoutCoordinatorService', () => {
});
it('keeps a bounded interaction window on the main thread before handing off the tail', () => {
const requestAnimationFrameSpy = vi.fn((callback: FrameRequestCallback) =>
window.setTimeout(() => callback(performance.now()), 0));
vi.stubGlobal('requestAnimationFrame', requestAnimationFrameSpy);
const steps = [
createProgress({
anchorReady: true,
@@ -182,6 +256,9 @@ describe('DocLayoutCoordinatorService', () => {
onProgress: vi.fn(),
onForegroundReady,
});
expect(skeleton.stepIncrementalLayout).toHaveBeenCalledTimes(1);
expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(1);
vi.runAllTimers();
expect(skeleton.stepIncrementalLayout).toHaveBeenCalledTimes(4);
@@ -0,0 +1,57 @@
/**
* 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 { BehaviorSubject } from 'rxjs';
import { describe, expect, it, vi } from 'vitest';
import { DocLayoutProgressService } from '../doc-layout-progress.service';
describe('DocLayoutProgressService', () => {
it('tracks only the active document, suppresses duplicates, and completes on disposal', () => {
const document$ = new BehaviorSubject<DocumentDataModel | null>(null);
const service = new DocLayoutProgressService({
getCurrentTypeOfUnit$: vi.fn(() => document$),
} as never);
const values: Array<{ progress: number; unitId: string } | null> = [];
let complete = false;
service.currentProgress$.subscribe({
complete: () => complete = true,
next: (value) => values.push(value),
});
const firstDocument = { getUnitId: () => 'doc-1' } as DocumentDataModel;
const secondDocument = { getUnitId: () => 'doc-2' } as DocumentDataModel;
document$.next(firstDocument);
service.setProgress('doc-1', 20);
service.setProgress('doc-1', 20);
service.setProgress('doc-2', 40);
document$.next(secondDocument);
service.clearProgress('doc-1');
service.clearProgress('doc-2');
service.clearProgress('missing-doc');
document$.next(null);
expect(values).toEqual([
null,
{ progress: 20, unitId: 'doc-1' },
{ progress: 40, unitId: 'doc-2' },
null,
]);
service.dispose();
expect(complete).toBe(true);
});
});
@@ -14,14 +14,20 @@
* limitations under the License.
*/
import type { DocumentDataModel, IDocumentData } from '@univerjs/core';
import type { RenderUnit } from '@univerjs/engine-render';
import type { IPopup } from '@univerjs/ui';
import { EventSubject, ICommandService, Injector, IUniverInstanceService } from '@univerjs/core';
import { DocSkeletonManagerService, RichTextEditingMutation } from '@univerjs/docs';
import { IRenderManagerService } from '@univerjs/engine-render';
import { BooleanNumber, DocumentFlavor, EventSubject, ICommandService, Injector, IUniverInstanceService, Univer, UniverInstanceType } from '@univerjs/core';
import { DocLayoutExecutorService, DocSkeletonManagerService, RichTextEditingMutation } from '@univerjs/docs';
import { CanvasColorService, Documents, ICanvasColorService, IRenderManagerService, RenderManagerService } from '@univerjs/engine-render';
import { ICanvasPopupService } from '@univerjs/ui';
import { describe, expect, it, vi } from 'vitest';
import { SetDocZoomRatioOperation } from '../../commands/operations/set-doc-zoom-ratio.operation';
import { DocCanvasPopupLayoutInteractionController } from '../../controllers/render-controllers/doc-canvas-popup-layout-interaction.controller';
import { calcDocGlyphPosition } from '../doc-event-manager.service';
import { DocLayoutInteractionService } from '../doc-layout-interaction.service';
import {
calcDocRangePositions,
DocCanvasPopManagerService,
transformBound2OffsetBound,
transformOffset2Bound,
@@ -29,6 +35,70 @@ import {
} from '../doc-popup-manager.service';
import { NodePositionConvertToCursor } from '../selection/convert-text-range';
describe('popup anchors with real document layout', () => {
it.each([0, 1, 3])('covers a %i-character range through its trailing edge', (length) => {
const context = 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(context as never);
const univer = new Univer();
try {
const injector = univer.__getInjector();
injector.add([IRenderManagerService, { useClass: RenderManagerService }]);
injector.add([ICanvasColorService, { useClass: CanvasColorService }]);
injector.add([DocLayoutExecutorService]);
const model = univer.createUnit<IDocumentData, DocumentDataModel>(UniverInstanceType.UNIVER_DOC, {
id: 'popup-anchor-test',
body: {
dataStream: 'A目CDZ\r\n',
paragraphs: [{ startIndex: 5, paragraphId: 'paragraph-1' }],
sectionBreaks: [{ startIndex: 6, sectionId: 'body' }],
},
documentStyle: {
documentFlavor: DocumentFlavor.TRADITIONAL,
autoHyphenation: BooleanNumber.FALSE,
pageSize: { width: 300, height: 400 },
marginTop: 20,
marginBottom: 20,
marginLeft: 20,
marginRight: 20,
},
});
const render = injector.get(IRenderManagerService).createRender(model.getUnitId()) as RenderUnit;
render.deactivate();
render.engine.resizeBySize(300, 400);
const canvas = render.engine.getCanvasElement()!;
vi.spyOn(canvas, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 0, 300, 400));
render.addRenderDependencies([[DocSkeletonManagerService]]);
const skeleton = render.with(DocSkeletonManagerService).getSkeleton();
const documents = new Documents('popup-anchor-document', skeleton);
render.mainComponent = documents;
render.scene.addObject(documents);
const bounds = calcDocRangePositions({ startOffset: 1, endOffset: 1 + length, collapsed: length === 0 }, render)!;
const lastGlyph = skeleton.findNodeByCharIndex(Math.max(1, length))!;
const lastGlyphBounds = calcDocGlyphPosition(lastGlyph, documents, skeleton)!;
expect(bounds).toHaveLength(1);
if (length === 0) {
expect(bounds[0].right).toBe(bounds[0].left);
} else {
expect(bounds[0].right).toBeCloseTo(lastGlyphBounds.right);
expect(bounds[0].right).toBeGreaterThan(bounds[0].left);
}
} finally {
univer.dispose();
vi.restoreAllMocks();
}
});
});
class TestCanvasPopupService {
activePopupId = '';
readonly popups = new Map<string, IPopup>();
@@ -171,6 +241,7 @@ function createService() {
injector.add([DocCanvasPopManagerService]);
return {
injector,
service: injector.get(DocCanvasPopManagerService),
popupService: injector.get(ICanvasPopupService) as unknown as TestCanvasPopupService,
renderManagerService: injector.get(IRenderManagerService) as unknown as TestRenderManagerService,
@@ -180,6 +251,32 @@ function createService() {
}
describe('DocCanvasPopManagerService', () => {
it('keeps passive hover popups mounted without pausing layout or releasing an active menu lock', () => {
const { injector, service, popupService } = createService();
injector.add([DocLayoutInteractionService]);
injector.add([DocCanvasPopupLayoutInteractionController, {
useFactory: () => injector.createInstance(DocCanvasPopupLayoutInteractionController, { unitId: 'doc-1' }),
}]);
injector.get(DocCanvasPopupLayoutInteractionController);
const interaction = injector.get(DocLayoutInteractionService);
const rect = { left: 10, right: 110, top: 20, bottom: 40 };
const hover = service.attachPopupToRect(rect, {
componentKey: 'passive-hover',
requiresStableLayout: false,
}, 'doc-1');
expect(popupService.popups.size).toBe(1);
expect(interaction.isActive).toBe(false);
const menu = service.attachPopupToRect(rect, { componentKey: 'active-menu' }, 'doc-1');
expect(interaction.isActive).toBe(true);
hover.dispose();
hover.dispose();
expect(interaction.isActive).toBe(true);
menu.dispose();
expect(interaction.isActive).toBe(false);
injector.dispose();
});
it('converts between document bounds and viewport offsets with scroll and scale', () => {
const renderManagerService = new TestRenderManagerService();
renderManagerService.scale = 2;
@@ -61,7 +61,9 @@ const calcDocRangePositions = (range: ITextRangeParam, documents: Documents, ske
end = Math.min(root.ed, end);
}
}
const endPosition = skeleton.findNodePositionByCharIndex(end, true, range.segmentId, pageIndex);
// `end` is inclusive: a non-collapsed range covers the final glyph,
// not just its leading caret edge (which gives single-glyph links no area).
const endPosition = skeleton.findNodePositionByCharIndex(end, range.collapsed === true, range.segmentId, pageIndex);
if (!endPosition || !startPosition) {
return;
}
@@ -30,6 +30,7 @@ const MIN_EDIT_FOREGROUND_WINDOW_SIZE = 3;
const MAX_EDIT_FOREGROUND_WINDOW_SIZE = 5;
const EDIT_FOREGROUND_WINDOW_SIZE = MAX_EDIT_FOREGROUND_WINDOW_SIZE;
const EDIT_FOREGROUND_BUDGET_MS = 28;
const MAX_SYNCHRONOUS_EDIT_STEPS = 4;
let docLayoutMountSequence = 0;
interface IInputPendingSchedulingHost {
@@ -101,6 +102,7 @@ export type DocumentLayoutSchedulingSkeleton = Pick<
> & Partial<Pick<DocumentSkeleton, 'publishIncrementalLayoutBacklog'>>;
type DocumentLayoutSchedulingOptions = NonNullable<Parameters<DocumentSkeleton['startIncrementalLayout']>[0]> & {
deferForeground?: boolean;
reason: DocumentLayoutReason;
foregroundWindowSize?: number;
foregroundBudgetMs?: number;
@@ -186,7 +188,7 @@ export class DocLayoutCoordinatorService extends Disposable {
): number {
this.cancel();
const generation = skeleton.startIncrementalLayout(options);
const generation = skeleton.startIncrementalLayout({ ...options, waitForHyphenationPatterns: true });
const backgroundResumeDelayMs = getBackgroundResumeDelay(options.reason);
this._scheduledLayout = {
executor: 'main-thread',
@@ -202,25 +204,34 @@ export class DocLayoutCoordinatorService extends Disposable {
foregroundWindowSize: normalizeEditForegroundWindowSize(options.foregroundWindowSize),
foregroundTargetProgressCount: null,
foregroundEndOffset: options.foregroundEndOffset ?? null,
waitForAnimationFrame: options.reason !== 'edit',
waitForAnimationFrame: options.reason !== 'edit' || options.deferForeground === true,
};
// Rich-text mutations update their logical selection in a microtask after the
// command has completed. Advance the edited anchor on the main thread before
// returning from the command callback so that the existing selection refresh
// closes over the newly committed skeleton, just as it did before background
// layout was introduced. A zero budget advances exactly one atomic block, so
// stop as soon as the anchor is publishable instead of spending the whole
// interaction-window budget in the command turn. Modern continuous layout
// then spends only the remaining budget on its visible suffix; any work
// still needed to reach that viewport boundary remains cooperatively scheduled.
if (options.reason === 'edit') {
// command has completed. Advance a small edited prefix in the command turn so
// ordinary caret edits retain their existing immediate visual publication.
// A large table can contain hundreds of atomic blocks before its anchor becomes
// publishable, so cap the synchronous prefix and finish that anchor across task
// boundaries. The logical selection remains authoritative until the foreground
// publication refreshes its render geometry.
if (options.reason === 'edit' && options.deferForeground !== true) {
this._scheduledLayout.foregroundStarted = true;
while (this._scheduledLayout != null && !this._scheduledLayout.anchorReady) {
let synchronousStepCount = 0;
while (
this._scheduledLayout != null &&
!this._scheduledLayout.anchorReady &&
synchronousStepCount < MAX_SYNCHRONOUS_EDIT_STEPS
) {
this._runSlice(0, false, false);
synchronousStepCount++;
}
const foregroundLayout = this._scheduledLayout;
const shouldYieldContinuationToVisualFrame = foregroundLayout?.executor === 'main-thread' && (
!foregroundLayout.anchorReady || foregroundLayout.callbacks.onForegroundReady != null
);
if (
foregroundLayout?.executor === 'main-thread' &&
!shouldYieldContinuationToVisualFrame &&
foregroundLayout.anchorReady &&
foregroundLayout.foregroundEndOffset != null &&
foregroundLayout.foregroundElapsedMs < foregroundLayout.foregroundBudgetMs
) {
@@ -232,14 +243,27 @@ export class DocLayoutCoordinatorService extends Disposable {
}
const scheduledLayout = this._scheduledLayout;
if (scheduledLayout != null) {
if (scheduledLayout.callbacks.onForegroundReady != null) {
if (
scheduledLayout.executor === 'main-thread' &&
(!scheduledLayout.anchorReady || scheduledLayout.callbacks.onForegroundReady != null)
) {
// The command turn already published a bounded caret prefix.
// Let that input paint before finishing a distant anchor or the
// surrounding interaction window, which can contain a large
// table or another atomic block whose single step exceeds its
// nominal time budget.
scheduledLayout.foregroundStarted = false;
scheduledLayout.waitForAnimationFrame = true;
}
if (!scheduledLayout.anchorReady || scheduledLayout.callbacks.onForegroundReady != null) {
this._scheduleForeground();
} else {
this._scheduleBackground();
}
}
} else {
// Initial layout still yields one frame so the application shell can paint.
// Initial layout and metadata-only edits yield one frame so the shell or
// direct-manipulation preview can paint before pagination resumes.
this._scheduleForeground();
}
@@ -365,6 +389,10 @@ export class DocLayoutCoordinatorService extends Disposable {
}
}
hasScheduledLayout(): boolean {
return this._scheduledLayout != null;
}
cancel(): void {
const scheduledLayout = this._scheduledLayout;
if (scheduledLayout != null) {
@@ -534,12 +562,17 @@ export class DocLayoutCoordinatorService extends Disposable {
return;
}
this._runMainThreadSlice(
scheduledLayout,
budgetMs,
publishOneBacklogPage,
scheduleContinuation
);
try {
this._runMainThreadSlice(
scheduledLayout,
budgetMs,
publishOneBacklogPage,
scheduleContinuation
);
} catch (error) {
this.cancel();
throw error;
}
}
private _runMainThreadSlice(
@@ -0,0 +1,77 @@
/**
* 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 { Observable } from 'rxjs';
import { IUniverInstanceService, RxDisposable, UniverInstanceType } from '@univerjs/core';
import { BehaviorSubject, combineLatest, distinctUntilChanged, map, takeUntil } from 'rxjs';
export interface IDocLayoutProgressState {
progress: number;
unitId: string;
}
export class DocLayoutProgressService extends RxDisposable {
private readonly _progressByUnit$ = new BehaviorSubject<ReadonlyMap<string, number>>(new Map());
readonly currentProgress$: Observable<IDocLayoutProgressState | null>;
constructor(@IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService) {
super();
this.currentProgress$ = combineLatest([
this._univerInstanceService.getCurrentTypeOfUnit$<DocumentDataModel>(UniverInstanceType.UNIVER_DOC),
this._progressByUnit$,
]).pipe(
map(([document, progressByUnit]): IDocLayoutProgressState | null => {
if (!document) {
return null;
}
const unitId = document.getUnitId();
const progress = progressByUnit.get(unitId);
return progress == null ? null : { progress, unitId };
}),
distinctUntilChanged((previous, current) => previous?.unitId === current?.unitId && previous?.progress === current?.progress),
takeUntil(this.dispose$)
);
}
setProgress(unitId: string, progress: number): void {
const current = this._progressByUnit$.value;
if (current.get(unitId) === progress) {
return;
}
this._progressByUnit$.next(new Map(current).set(unitId, progress));
}
clearProgress(unitId: string): void {
const current = this._progressByUnit$.value;
if (!current.has(unitId)) {
return;
}
const next = new Map(current);
next.delete(unitId);
this._progressByUnit$.next(next);
}
override dispose(): void {
this._progressByUnit$.complete();
super.dispose();
}
}
@@ -531,6 +531,8 @@ export class DocParagraphMenuService extends Disposable implements IRenderModule
getFirstLine,
{
componentKey: DOC_PARAGRAPH_MENU_COMPONENT_KEY,
// Hovering is passive; opened menus and drags hold their own interaction lease.
requiresStableLayout: false,
direction: 'left-center',
onClickOutside: () => {
if (this._isSlashMenuActive) {
@@ -618,6 +620,7 @@ export class DocParagraphMenuService extends Disposable implements IRenderModule
getFirstLine,
{
componentKey: DOC_TABLE_BLOCK_MENU_COMPONENT_KEY,
requiresStableLayout: false,
direction: 'top-right',
onClickOutside: () => {
this.hideParagraphMenu(true);
@@ -85,6 +85,8 @@ export function transformOffset2Bound(offsetX: number, offsetY: number, scene: S
}
export interface IDocCanvasPopup extends Omit<IPopup, 'anchorRect$' | 'children' | 'unitId' | 'subUnitId' | 'canvasElement'> {
/** Defaults to true. Passive hover affordances can track layout without holding it. */
requiresStableLayout?: boolean;
mask?: boolean;
extraProps?: Record<string, unknown>;
multipleDirection?: IPopup['direction'];
@@ -95,7 +97,8 @@ export const calcDocRangePositions = (range: ITextRangeParam, currentRender: IRe
const skeleton = currentRender.with(DocSkeletonManagerService).getSkeleton();
const startPosition = skeleton.findNodePositionByCharIndex(range.startOffset, true, range.segmentId, range.segmentPage);
const endIndex = range.collapsed ? range.startOffset : range.endOffset - 1;
const endPosition = skeleton.findNodePositionByCharIndex(endIndex, true, range.segmentId, range.segmentPage);
// Include the last glyph in the anchor and its outside-click exclusion.
const endPosition = skeleton.findNodePositionByCharIndex(endIndex, range.collapsed === true, range.segmentId, range.segmentPage);
const document = mainComponent as Documents;
if (!endPosition || !startPosition) {
@@ -329,14 +332,14 @@ export class DocCanvasPopManagerService extends Disposable {
anchorRect$: position$,
canvasElement: currentRender.engine.getCanvasElement(),
});
const popupActivity = this._beginPopupActivity(unitId);
const popupActivity = popup.requiresStableLayout === false ? null : this._beginPopupActivity(unitId);
return {
dispose: () => {
popupManagerService.removePopup(id);
position$.complete();
disposable.dispose();
popupActivity.dispose();
popupActivity?.dispose();
},
canDispose: () => popupManagerService.activePopupId !== id,
};
@@ -367,14 +370,14 @@ export class DocCanvasPopManagerService extends Disposable {
anchorRect$: position$,
canvasElement: currentRender.engine.getCanvasElement(),
});
const popupActivity = this._beginPopupActivity(unitId);
const popupActivity = popup.requiresStableLayout === false ? null : this._beginPopupActivity(unitId);
return {
dispose: () => {
popupManagerService.removePopup(id);
position$.complete();
disposable.dispose();
popupActivity.dispose();
popupActivity?.dispose();
},
canDispose: () => popupManagerService.activePopupId !== id,
};
@@ -421,14 +424,14 @@ export class DocCanvasPopManagerService extends Disposable {
: direction,
canvasElement: currentRender.engine.getCanvasElement(),
});
const popupActivity = this._beginPopupActivity(unitId);
const popupActivity = popup.requiresStableLayout === false ? null : this._beginPopupActivity(unitId);
return {
dispose: () => {
popupManagerService.removePopup(id);
bounds$.complete();
disposable.dispose();
popupActivity.dispose();
popupActivity?.dispose();
},
canDispose: () => popupManagerService.activePopupId !== id,
};
@@ -15,11 +15,20 @@
*/
import type { DisposableCollection } from '@univerjs/core';
import type { Documents, DocumentSkeleton, Engine, IBoundRectNoAngle, Scene } from '@univerjs/engine-render';
import type {
Documents,
DocumentSkeleton,
Engine,
IBoundRectNoAngle,
IDocumentSkeletonHeaderFooter,
IDocumentSkeletonPage,
Scene,
} from '@univerjs/engine-render';
import { createInterceptorKey, Disposable, InterceptorManager } from '@univerjs/core';
export interface IDocPrintContext {
unitId: string;
pageIndex?: number;
scene: Scene;
engine: Engine;
root: HTMLElement;
@@ -73,6 +82,30 @@ export class DocPrintInterceptorService extends Disposable {
return this._printComponentMap.get(componentKey);
}
getPageDrawingIds(skeleton: DocumentSkeleton, pageIndex?: number): ReadonlySet<string> | null {
const data = skeleton.getSkeletonData();
if (!data || data.pages.length <= 1 || pageIndex == null) {
return null;
}
const drawingIds = new Set<string>();
const page = data.pages[pageIndex];
if (!page) {
return drawingIds;
}
collectPageDrawingIds(page, drawingIds);
const header = data.skeHeaders.get(page.headerId)?.get(page.pageWidth);
const footer = data.skeFooters.get(page.footerId)?.get(page.pageWidth);
if (header) {
collectPageDrawingIds(header, drawingIds);
}
if (footer) {
collectPageDrawingIds(footer, drawingIds);
}
return drawingIds;
}
registerPrintPreparation(handler: (context: IDocPrintPreparationContext) => Promise<void>) {
this._printPreparationHandlers.add(handler);
return () => this._printPreparationHandlers.delete(handler);
@@ -87,3 +120,17 @@ export class DocPrintInterceptorService extends Disposable {
super.dispose();
}
}
function collectPageDrawingIds(page: IDocumentSkeletonPage | IDocumentSkeletonHeaderFooter, drawingIds: Set<string>): void {
for (const drawingId of page.skeDrawings.keys()) {
drawingIds.add(drawingId);
}
// Cell drawings belong to their cell skeleton, not the host page's map.
for (const table of page.skeTables.values()) {
for (const row of table.rows) {
for (const cell of row.cells) {
collectPageDrawingIds(cell, drawingIds);
}
}
}
}
@@ -655,3 +655,55 @@ describe('EditorService', () => {
expect(service.getEditorRenderConfig(EDITOR_ID)).toBeNull();
});
});
describe('EditorService preserved host focus regression', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('restores the focused host document after disposing the current comment editor', () => {
vi.stubGlobal('window', new EventTarget());
const editorUnitId = '__INTERNAL_EDITOR__COMMENT_EDITOR_PANEL_doc-1';
const hostUnitId = 'doc-1';
let currentUnitId: string | null = editorUnitId;
const setCurrentUnitForType = vi.fn((unitId: string) => {
currentUnitId = unitId;
});
const instanceService = {
getCurrentUnitOfType: vi.fn(() => currentUnitId == null ? null : { getUnitId: () => currentUnitId }),
getFocusedUnit: vi.fn(() => ({ getUnitId: () => hostUnitId })),
getUnitType: vi.fn(() => UniverInstanceType.UNIVER_DOC),
disposeUnit: vi.fn(() => {
currentUnitId = null;
return true;
}),
setCurrentUnitForType,
};
const removeRender = vi.fn();
const editorDispose = vi.fn();
const service = new EditorService(
instanceService as never,
{ removeRender } as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never
);
(service as unknown as { _editors: Map<string, unknown> })._editors.set(editorUnitId, {
dispose: editorDispose,
});
(service as unknown as { _editorRenderConfigs: Map<string, unknown> })._editorRenderConfigs.set(editorUnitId, {
preserveHostFocus: true,
});
(service as unknown as { _unRegister: (unitId: string) => void })._unRegister(editorUnitId);
expect(removeRender).toHaveBeenCalledWith(editorUnitId);
expect(editorDispose).toHaveBeenCalledTimes(1);
expect(instanceService.disposeUnit).toHaveBeenCalledWith(editorUnitId);
expect(setCurrentUnitForType).toHaveBeenCalledWith(hostUnitId);
expect(currentUnitId).toBe(hostUnitId);
service.dispose();
});
});
@@ -298,11 +298,25 @@ export class EditorService extends Disposable implements IEditorService, IDispos
return;
}
const preserveHostFocus = this._editorRenderConfigs.get(editorUnitId)?.preserveHostFocus === true;
const currentDoc = this._univerInstanceService.getCurrentUnitOfType<DocumentDataModel>(UniverInstanceType.UNIVER_DOC);
const focusedHost = preserveHostFocus ? this._univerInstanceService.getFocusedUnit() : null;
const focusedHostId = focusedHost?.getUnitId();
const restoreHostUnitId = currentDoc?.getUnitId() === editorUnitId
&& focusedHostId != null
&& focusedHostId !== editorUnitId
&& this._univerInstanceService.getUnitType(focusedHostId) === UniverInstanceType.UNIVER_DOC
? focusedHostId
: null;
this._renderManagerService.removeRender(editorUnitId);
editor.dispose();
this._editors.delete(editorUnitId);
this._editorRenderConfigs.delete(editorUnitId);
this._univerInstanceService.disposeUnit(editorUnitId);
if (restoreHostUnitId != null) {
this._univerInstanceService.setCurrentUnitForType(restoreHostUnitId);
}
}
private _getCurrentEditorUnitId() {
@@ -18,12 +18,22 @@
import type { IDisposable, IDocumentData } from '@univerjs/core';
import type { Mock } from 'vitest';
import { DataStreamTreeTokenType, DOC_RANGE_TYPE, DOCS_NORMAL_EDITOR_UNIT_ID_KEY, Univer, UniverInstanceType } from '@univerjs/core';
import { DocSkeletonManagerService } from '@univerjs/docs';
import {
DataStreamTreeTokenType,
DOC_RANGE_TYPE,
DOCS_NORMAL_EDITOR_UNIT_ID_KEY,
Univer,
UniverInstanceType,
} from '@univerjs/core';
import { DocSelectionManagerService, DocSkeletonManagerService } from '@univerjs/docs';
import { GlyphType, RenderUnit } from '@univerjs/engine-render';
import { ILayoutService } from '@univerjs/ui';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE, EmbedInteractionBoundaryService, EmbedRuntimeFocusCoordinator } from '../../doc-embed-integration.service';
import {
EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE,
EmbedInteractionBoundaryService,
EmbedRuntimeFocusCoordinator,
} from '../../doc-embed-integration.service';
import { DocSelectionRenderService } from '../doc-selection-render.service';
import { TextRange } from '../text-range';
@@ -246,15 +256,18 @@ class TestLayoutService {
class TestDocSkeletonManagerService {
static skeleton: {
getLayoutProgress: () => null;
findPositionByGlyph: () => unknown;
findNodeByCoord: () => unknown;
} = {
getLayoutProgress: () => null,
findPositionByGlyph: () => null,
findNodeByCoord: () => null,
};
static reset() {
this.skeleton = {
getLayoutProgress: () => null,
findPositionByGlyph: () => null,
findNodeByCoord: () => null,
};
@@ -302,6 +315,7 @@ function createRealSelectionRenderService(options: {
TestDocSkeletonManagerService.reset();
const univer = new Univer();
const injector = univer.__getInjector();
injector.add([DocSelectionManagerService]);
injector.add([ILayoutService, { useClass: TestLayoutService as never }]);
if (options.embedInteractionBoundaryService) {
injector.add([EmbedInteractionBoundaryService, { useValue: options.embedInteractionBoundaryService as never }]);
@@ -872,19 +886,6 @@ describe('doc selection render service internals', () => {
expect(engine.setCapture).not.toHaveBeenCalled();
});
it('clears ranges when manual cursor placement cannot resolve a node position', () => {
const { service } = createService();
const removeAllRangesSpy = vi.fn();
service._removeAllRanges = removeAllRangesSpy;
service._getNodePosition.mockReturnValue(null);
service.setCursorManually(4, 8);
expect(removeAllRangesSpy).toHaveBeenCalledTimes(1);
expect(service._textSelectionInner$.next).not.toHaveBeenCalled();
});
it('reads canvas offsets from the current engine', () => {
const { engine, service } = createService();
@@ -955,6 +956,49 @@ describe('DocSelectionRenderService', () => {
document.body.innerHTML = '';
});
it.each(['pointer', 'manual', 'double', 'triple'] as const)('clears a deferred input position when %s placement cannot resolve a caret', (placement) => {
const { input, renderUnit, service, univer } = createRealSelectionRenderService({
mainComponent: { getOffsetConfig: () => ({}) },
});
cleanup.push(() => renderUnit.dispose(), () => univer.dispose());
const manager = univer.__getInjector().get(DocSelectionManagerService);
manager.__TEST_ONLY_add([{ startOffset: 2, endOffset: 2, collapsed: true, isActive: true }]);
const selection = manager.getSelectionInfo()!;
const state = service as unknown as { _pendingSelection: typeof selection };
state._pendingSelection = selection;
const received: Array<number | null> = [];
const inputSubscription = service.onInput$.subscribe((event) => received.push(event.activeRange?.startOffset ?? null));
const selectionSubscription = service.textSelectionInner$.subscribe((next) => {
if (next != null) {
manager.replaceSelectionInfoWithoutRefresh(next);
}
});
cleanup.push(() => inputSubscription.unsubscribe(), () => selectionSubscription.unsubscribe());
service.focus();
input.textContent = 'A';
input.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: 'A' }));
expect(received[0]).toBe(2);
const event = { offsetX: 10, offsetY: 10, button: 0 } as never;
if (placement === 'manual') {
service.setCursorManually(10, 10);
} else if (placement === 'double') {
service.__handleDblClick(event);
} else if (placement === 'triple') {
service.__handleTripleClick(event);
} else {
service.__onPointDown(event);
}
input.textContent = 'B';
input.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: 'B' }));
expect(received[1]).toBeNull();
expect(manager.getTextRanges()).toEqual([]);
expect(service.getAllTextRanges()).toEqual([]);
expect(document.activeElement).not.toBe(input);
});
it('keeps the editable input inside the Univer layout root and registers it as an app container', () => {
const { renderUnit, service, univer } = createRealSelectionRenderService();
cleanup.push(() => renderUnit.dispose(), () => univer.dispose());
@@ -1726,6 +1770,7 @@ describe('DocSelectionRenderService', () => {
const { renderUnit, service, univer } = createRealSelectionRenderService({ mainComponent, scene });
cleanup.push(() => renderUnit.dispose(), () => univer.dispose());
TestDocSkeletonManagerService.skeleton = {
getLayoutProgress: () => null,
findNodeByCoord,
findPositionByGlyph,
};
@@ -15,11 +15,37 @@
*/
import type { DocumentDataModel, Nullable } from '@univerjs/core';
import type { Documents, DocumentSkeleton, Engine, IDocSelectionInnerParam, IFindNodeRestrictions, IMouseEvent, INodeInfo, INodePosition, IPointerEvent, IRenderContext, IRenderModule, IScrollObserverParam, ISuccinctDocRangeParam, ITextRangeWithStyle, ITextSelectionStyle } from '@univerjs/engine-render';
import type {
Documents,
DocumentSkeleton,
Engine,
IDocSelectionInnerParam,
IFindNodeRestrictions,
IMouseEvent,
INodeInfo,
INodePosition,
IPointerEvent,
IRenderContext,
IRenderModule,
IScrollObserverParam,
ISuccinctDocRangeParam,
ITextRangeWithStyle,
ITextSelectionStyle,
} from '@univerjs/engine-render';
import type { Subscription } from 'rxjs';
import type { RectRange } from './rect-range';
import { DataStreamTreeTokenType, DOC_RANGE_TYPE, ILogService, Inject, isInternalEditorID, IUniverInstanceService, Optional, RxDisposable, UniverInstanceType } from '@univerjs/core';
import { DocSkeletonManagerService } from '@univerjs/docs';
import {
DataStreamTreeTokenType,
DOC_RANGE_TYPE,
ILogService,
Inject,
isInternalEditorID,
IUniverInstanceService,
Optional,
RxDisposable,
UniverInstanceType,
} from '@univerjs/core';
import { DocSelectionManagerService, DocSkeletonManagerService } from '@univerjs/docs';
import {
CURSOR_TYPE,
getSystemHighlightColor,
@@ -31,7 +57,11 @@ import {
} from '@univerjs/engine-render';
import { ILayoutService, KeyCode } from '@univerjs/ui';
import { BehaviorSubject, filter, fromEvent, merge, Subject, takeUntil } from 'rxjs';
import { DOC_EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE, IDocEmbedInteractionBoundaryService, IDocEmbedRuntimeFocusCoordinator } from '../doc-embed-integration.service';
import {
DOC_EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE,
IDocEmbedInteractionBoundaryService,
IDocEmbedRuntimeFocusCoordinator,
} from '../doc-embed-integration.service';
import { compareNodePositionLogic } from './convert-text-range';
import {
getCanvasOffsetByEngine,
@@ -130,6 +160,11 @@ export class DocSelectionRenderService extends RxDisposable implements IRenderMo
private _scenePointerUpSubs: Array<Subscription> = [];
// When the user switches editors, whether to clear the doc ranges.
private _reserveRanges = false;
private _pendingSelection: Nullable<IDocSelectionInnerParam> = null;
get hasPendingSelection(): boolean {
return this._getPendingSelection() != null;
}
get isOnPointerEvent() {
return this._onPointerEvent;
@@ -174,6 +209,7 @@ export class DocSelectionRenderService extends RxDisposable implements IRenderMo
@ILogService private readonly _logService: ILogService,
@IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService,
@Inject(DocSkeletonManagerService) private readonly _docSkeletonManagerService: DocSkeletonManagerService,
@Inject(DocSelectionManagerService) private readonly _docSelectionManagerService: DocSelectionManagerService,
@Optional(IDocEmbedInteractionBoundaryService) private readonly _embedInteractionBoundaryService?: IDocEmbedInteractionBoundaryService,
@Optional(IDocEmbedRuntimeFocusCoordinator) private readonly _embedRuntimeFocusCoordinator?: IDocEmbedRuntimeFocusCoordinator
) {
@@ -242,9 +278,43 @@ export class DocSelectionRenderService extends RxDisposable implements IRenderMo
}
replaceDocRanges(ranges: ISuccinctDocRangeParam[], isEditing = true, options?: { [key: string]: boolean }): boolean {
// A drag owns its in-progress ranges until pointerup publishes them.
if (this._onPointerEvent) {
this._pendingSelection = null;
return false;
}
if (this._currentSegmentId === '' && ranges.length > 0) {
const progress = this._docSkeletonManagerService.getSkeleton().getLayoutProgress();
if (progress?.reason === 'edit' && !progress.anchorReady && !progress.complete && !progress.cancelled) {
const { unitId } = this._context;
const selection = this._docSelectionManagerService.getSelectionInfo({ unitId, subUnitId: unitId });
// Only a refresh of the current logical selection belongs to this
// publication. A new pointer/programmatic selection takes over.
if (
selection != null &&
ranges.length === selection.textRanges.length &&
ranges.every((range, index) => range === selection.textRanges[index])
) {
this._pendingSelection = selection;
return false;
}
}
}
this._pendingSelection = null;
return this.addDocRanges(ranges, isEditing, options, true);
}
private _getPendingSelection(): Nullable<IDocSelectionInnerParam> {
if (this._pendingSelection == null) {
return null;
}
const { unitId } = this._context;
if (this._docSelectionManagerService.getSelectionInfo({ unitId, subUnitId: unitId }) !== this._pendingSelection) {
this._pendingSelection = null;
}
return this._pendingSelection;
}
private _canResolveCollapsedRanges(
ranges: ISuccinctDocRangeParam[],
scene: IRenderContext<DocumentDataModel>['scene'],
@@ -436,7 +506,7 @@ export class DocSelectionRenderService extends RxDisposable implements IRenderMo
const position = this._getNodePosition(startNode);
if (position == null) {
this._removeAllRanges();
this._clearUnresolvedSelection();
return;
}
@@ -524,6 +594,7 @@ export class DocSelectionRenderService extends RxDisposable implements IRenderMo
segmentPage: this._currentSegmentPage,
});
if (startNode == null || startNode.node == null) {
this._clearUnresolvedSelection();
return;
}
@@ -562,6 +633,7 @@ export class DocSelectionRenderService extends RxDisposable implements IRenderMo
segmentPage: this._currentSegmentPage,
});
if (startNode == null || startNode.node == null) {
this._clearUnresolvedSelection();
return;
}
@@ -601,7 +673,7 @@ export class DocSelectionRenderService extends RxDisposable implements IRenderMo
const position = this._getNodePosition(startNode);
if (position == null || startNode == null) {
this._removeAllRanges();
this._clearUnresolvedSelection();
return;
}
@@ -622,6 +694,7 @@ export class DocSelectionRenderService extends RxDisposable implements IRenderMo
}
}
this._pendingSelection = null;
const { segmentId, segmentPage } = startNode;
if (segmentId && this._currentSegmentId && segmentId !== this._currentSegmentId) {
@@ -730,7 +803,19 @@ export class DocSelectionRenderService extends RxDisposable implements IRenderMo
}));
}
private _clearUnresolvedSelection(): void {
// A new pointer target takes ownership even before its page is ready.
// Clear the logical selection as well as its geometry so a later layout
// publication or native input cannot restore the previous caret.
this._anchorNodePosition = null;
this._focusNodePosition = null;
this._removeAllCacheRanges();
this.addDocRanges([], false, { shouldFocus: false }, true);
this.blur();
}
removeAllRanges() {
this._pendingSelection = null;
this._removeAllRanges();
this.deactivate();
}
@@ -798,6 +883,7 @@ export class DocSelectionRenderService extends RxDisposable implements IRenderMo
}
override dispose() {
this._pendingSelection = null;
super.dispose();
this._detachEvent();
this._removeAllRanges();
@@ -1449,8 +1535,19 @@ export class DocSelectionRenderService extends RxDisposable implements IRenderMo
this._input.innerHTML = '';
const activeRange = this._getActiveRange();
const rangeList = this._getAllTextRanges();
// Geometry remains on the previous page until the edited page is ready.
// Continue native input from the existing logical selection only while
// that exact selection is waiting for publication.
const pendingSelection = this._getPendingSelection();
const rangeList = pendingSelection?.textRanges.map((range) => ({
segmentId: pendingSelection.segmentId,
segmentPage: pendingSelection.segmentPage,
style: pendingSelection.style,
...range,
})) ?? this._getAllTextRanges();
const activeRange = pendingSelection == null
? this._getActiveRange()
: rangeList.find((range) => range.isActive);
func({
event: e,
@@ -15,8 +15,10 @@
*/
import { BooleanNumber } from '@univerjs/core';
import { HeaderFooterType } from '@univerjs/docs';
import { DocumentEditArea } from '@univerjs/engine-render';
import { describe, expect, it } from 'vitest';
import { getDocPageSectionContext } from '../section-header-footer';
import { getDocPageSectionContext, getHeaderFooterTarget } from '../section-header-footer';
describe('section header/footer context', () => {
it('resolves the owning section and overlays it on document defaults', () => {
@@ -88,4 +90,58 @@ describe('section header/footer context', () => {
config: { defaultHeaderId: 'section-header' },
});
});
it('resolves existing and missing header/footer variants for the current page', () => {
const snapshot = {
id: 'header-footer-target-doc',
documentStyle: {
defaultHeaderId: 'default-header',
evenPageFooterId: 'even-footer',
evenAndOddHeaders: BooleanNumber.TRUE,
useFirstPageHeaderFooter: BooleanNumber.TRUE,
},
body: { dataStream: '\r\n' },
};
const viewModel = {
getDataModel: () => ({ getSnapshot: () => snapshot }),
};
expect(getHeaderFooterTarget(viewModel as never, DocumentEditArea.HEADER, 0, {
pageNumber: 1,
pageNumberStart: 1,
} as never)).toEqual({
createType: HeaderFooterType.FIRST_PAGE_HEADER,
headerFooterId: null,
sectionId: undefined,
});
expect(getHeaderFooterTarget(viewModel as never, DocumentEditArea.FOOTER, 1, {
pageNumber: 2,
pageNumberStart: 1,
} as never)).toEqual({
createType: null,
headerFooterId: 'even-footer',
sectionId: undefined,
});
expect(getHeaderFooterTarget(viewModel as never, DocumentEditArea.HEADER, 2, {
pageNumber: 3,
pageNumberStart: 1,
} as never)).toEqual({
createType: null,
headerFooterId: 'default-header',
sectionId: undefined,
});
});
it('returns no target for body editing and rejects unsupported edit areas', () => {
const viewModel = {
getDataModel: () => ({ getSnapshot: () => ({ id: 'doc', documentStyle: {} }) }),
};
expect(getHeaderFooterTarget(viewModel as never, DocumentEditArea.BODY, 0)).toEqual({
createType: null,
headerFooterId: null,
sectionId: undefined,
});
expect(() => getHeaderFooterTarget(viewModel as never, 'invalid' as never, 0)).toThrow('Invalid editArea');
});
});
@@ -14,10 +14,11 @@
* limitations under the License.
*/
import type { IDocumentData, IDocumentStyle, ISectionBreak } from '@univerjs/core';
import type { IDocumentSkeletonPage } from '@univerjs/engine-render';
import { resolveSectionHeaderFooterReferences } from '@univerjs/core';
import { getTopLevelSectionBreaks } from '@univerjs/docs';
import type { IDocumentData, IDocumentStyle, ISectionBreak, Nullable } from '@univerjs/core';
import type { DocumentViewModel, IDocumentSkeletonPage } from '@univerjs/engine-render';
import { BooleanNumber, resolveSectionHeaderFooterReferences } from '@univerjs/core';
import { getTopLevelSectionBreaks, HeaderFooterType } from '@univerjs/docs';
import { DocumentEditArea } from '@univerjs/engine-render';
export interface IDocPageSectionContext {
sectionId?: string;
@@ -28,6 +29,12 @@ export interface IDocPageSectionContext {
config: IDocumentStyle & Partial<ISectionBreak>;
}
export interface IHeaderFooterTarget {
createType: Nullable<HeaderFooterType>;
headerFooterId: Nullable<string>;
sectionId?: string;
}
export function getDocPageSectionContext(
snapshot: IDocumentData,
page?: Pick<IDocumentSkeletonPage, 'sectionId'>
@@ -51,3 +58,56 @@ export function getDocPageSectionContext(
},
};
}
export function getHeaderFooterTarget(
viewModel: DocumentViewModel,
editArea: DocumentEditArea,
segmentPage: number,
page?: IDocumentSkeletonPage
): IHeaderFooterTarget {
const snapshot = viewModel.getDataModel().getSnapshot();
const { sectionId, config } = getDocPageSectionContext(snapshot, page);
const {
defaultHeaderId,
defaultFooterId,
evenPageHeaderId,
evenPageFooterId,
firstPageHeaderId,
firstPageFooterId,
evenAndOddHeaders,
useFirstPageHeaderFooter,
} = config;
const isFirstPage = page ? page.pageNumber === page.pageNumberStart : segmentPage === 0;
const isEvenPage = page ? page.pageNumber % 2 === 0 : segmentPage % 2 === 1;
if (editArea === DocumentEditArea.BODY) {
return { createType: null, headerFooterId: null, sectionId };
}
const isHeader = editArea === DocumentEditArea.HEADER;
if (!isHeader && editArea !== DocumentEditArea.FOOTER) {
throw new Error(`Invalid editArea: ${editArea}`);
}
const variants = isHeader
? {
first: [firstPageHeaderId, HeaderFooterType.FIRST_PAGE_HEADER] as const,
even: [evenPageHeaderId, HeaderFooterType.EVEN_PAGE_HEADER] as const,
default: [defaultHeaderId, HeaderFooterType.DEFAULT_HEADER] as const,
}
: {
first: [firstPageFooterId, HeaderFooterType.FIRST_PAGE_FOOTER] as const,
even: [evenPageFooterId, HeaderFooterType.EVEN_PAGE_FOOTER] as const,
default: [defaultFooterId, HeaderFooterType.DEFAULT_FOOTER] as const,
};
const [headerFooterId, createType] = useFirstPageHeaderFooter === BooleanNumber.TRUE && isFirstPage
? variants.first
: evenAndOddHeaders === BooleanNumber.TRUE && isEvenPage
? variants.even
: variants.default;
return {
createType: headerFooterId ? null : createType,
headerFooterId: headerFooterId ?? null,
sectionId,
};
}
@@ -0,0 +1,63 @@
/**
* 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 { LocaleService } from '@univerjs/core';
import { useDependency, useObservable } from '@univerjs/ui';
import { DocLayoutProgressService } from '../services/doc-layout-progress.service';
export function DocLayoutProgress() {
const localeService = useDependency(LocaleService);
const progressState = useObservable(useDependency(DocLayoutProgressService).currentProgress$, null);
if (progressState == null) {
return null;
}
const { progress } = progressState;
const label = localeService.t('docs-ui.layout.progress');
return (
<div
className="
univer-pointer-events-none univer-absolute univer-left-1/2 univer-top-1/2 univer-flex
-univer-translate-x-1/2 -univer-translate-y-1/2 univer-items-center univer-gap-2 univer-text-xs
univer-text-gray-600
dark:univer-text-gray-300
"
>
<span className="univer-whitespace-nowrap">{label}</span>
<div
aria-label={label}
aria-valuemax={100}
aria-valuemin={0}
aria-valuenow={progress}
className="
univer-h-1 univer-w-32 univer-overflow-hidden univer-rounded-full univer-bg-gray-200
dark:univer-bg-gray-700
"
role="progressbar"
>
<div
className="univer-h-full univer-rounded-full univer-bg-primary-600 univer-transition-[width]"
style={{ width: `${progress}%` }}
/>
</div>
<span className="univer-w-8 univer-tabular-nums">
{progress}
%
</span>
</div>
);
}
@@ -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 { renderToStaticMarkup } from 'react-dom/server';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { DocLayoutProgress } from '../DocLayoutProgress';
const mocks = vi.hoisted(() => ({
progress: null as { progress: number; unitId: string } | null,
}));
vi.mock('@univerjs/ui', () => ({
useDependency: (token: { name?: string }) => token.name === 'LocaleService'
? { t: () => 'Laying out document…' }
: { currentProgress$: {} },
useObservable: () => mocks.progress,
}));
describe('DocLayoutProgress', () => {
beforeEach(() => {
mocks.progress = null;
});
it('stays hidden without active layout progress', () => {
expect(renderToStaticMarkup(<DocLayoutProgress />)).toBe('');
});
it('renders localized, centered footer progress with accessible values', () => {
mocks.progress = { progress: 19, unitId: 'doc-1' };
const markup = renderToStaticMarkup(<DocLayoutProgress />);
expect(markup).toContain('Laying out document…');
expect(markup).not.toContain('aria-live');
expect(markup).toContain('role="progressbar"');
expect(markup).toContain('aria-valuenow="19"');
expect(markup).toContain('width:19%');
expect(markup).toContain('univer-absolute univer-left-1/2 univer-top-1/2');
expect(markup).toContain('>19%</span>');
});
});
@@ -20,6 +20,7 @@ import { IWorkbenchService, useConfigValue, useDependency, useObservable } from
import { DOCS_UI_PLUGIN_CONFIG_KEY } from '../../config/config';
import { CountBar } from '../count-bar';
import { DocStatistics } from '../doc-statistics/DocStatistics';
import { DocLayoutProgress } from '../DocLayoutProgress';
function DocFooterContent() {
const config = useConfigValue<IUniverDocsUIConfig>(DOCS_UI_PLUGIN_CONFIG_KEY);
@@ -28,10 +29,12 @@ function DocFooterContent() {
return isShow && (
<div
className={`
univer-box-border univer-flex univer-items-center univer-justify-between univer-px-5 univer-py-1.5
univer-relative univer-box-border univer-flex univer-items-center univer-justify-between univer-px-5
univer-py-1.5
`}
>
{config?.wordCount !== false && <DocStatistics />}
<DocLayoutProgress />
<CountBar />
</div>
);
@@ -14,8 +14,11 @@
* limitations under the License.
*/
import { describe, expect, it } from 'vitest';
import { transformDocumentTextRanges } from '../core-editing.mutation';
import { ICommandService, JSONX } from '@univerjs/core';
import { describe, expect, it, vi } from 'vitest';
import { createTestBed } from '../../../facade/__tests__/create-test-bed';
import { DocSelectionManagerService } from '../../../services/doc-selection-manager.service';
import { RichTextEditingMutation, transformDocumentTextRanges } from '../core-editing.mutation';
describe('transformDocumentTextRanges', () => {
it('moves an active body caret through an insertion with right priority', () => {
@@ -71,3 +74,29 @@ describe('transformDocumentTextRanges', () => {
}], [range])).toEqual([range]);
});
});
describe('RichTextEditingMutation selection scheduling', () => {
it('does not refresh selection for metadata changes without ranges', async () => {
const testBed = createTestBed();
const selectionManager = testBed.get(DocSelectionManagerService);
const refreshSelection = vi.spyOn(selectionManager, 'refreshSelection');
const documentStyle = testBed.doc.getDocumentStyle();
const previousMargin = documentStyle.marginTop;
const actions = JSONX.getInstance().replaceOp(
['documentStyle', 'marginTop'],
previousMargin,
(previousMargin ?? 0) + 1
);
testBed.get(ICommandService).syncExecuteCommand(RichTextEditingMutation.id, {
unitId: testBed.doc.getUnitId(),
actions,
textRanges: [],
trigger: 'test.metadata-command',
});
await Promise.resolve();
expect(refreshSelection).not.toHaveBeenCalled();
testBed.univer.dispose();
});
});
@@ -0,0 +1,47 @@
/**
* 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 { JSONX } from '@univerjs/core';
import { describe, expect, it, vi } from 'vitest';
import { validateDocStructureMutation } from '../doc-structure-mutation-validation';
describe('validateDocStructureMutation', () => {
it('skips body structure scans for table metadata updates', () => {
const getSelfOrHeaderFooterModel = vi.fn(() => {
throw new Error('body structure should not be scanned');
});
const model = {
getSnapshot: () => ({}),
getSelfOrHeaderFooterModel,
} as unknown as DocumentDataModel;
const actions = JSONX.getInstance().replaceOp(
['tableSource', 'table-1', 'tableColumns', 0, 'size', 'width', 'v'],
80,
96
);
const undoActions = JSONX.getInstance().replaceOp(
['tableSource', 'table-1', 'tableColumns', 0, 'size', 'width', 'v'],
96,
80
);
// Regression: table resize on a 560-page document spent hundreds of milliseconds
// validating an unchanged body before every visual update.
expect(validateDocStructureMutation(model, '', actions, undoActions)).toBe(true);
expect(getSelfOrHeaderFooterModel).not.toHaveBeenCalled();
});
});
@@ -14,7 +14,15 @@
* limitations under the License.
*/
import type { DocumentDataModel, IExecutionOptions, IMutation, IMutationCommonParams, JSONXActions, Nullable, TPriority } from '@univerjs/core';
import type {
DocumentDataModel,
IExecutionOptions,
IMutation,
IMutationCommonParams,
JSONXActions,
Nullable,
TPriority,
} from '@univerjs/core';
import type { DocumentViewModel, ITextRangeWithStyle } from '@univerjs/engine-render';
import type { IDocStateChangeInfo } from '../../services/doc-state-emit.service';
import {
@@ -144,8 +152,10 @@ function resetDocumentViewModel(
if (documentViewModel == null) {
return;
}
const didResetIncrementally = segmentId === '' && preservesStructure &&
documentViewModel.resetByValidatedTextMutation(documentDataModel, actions);
const didResetIncrementally = segmentId === '' && preservesStructure && (
documentViewModel.resetByValidatedTextMutation?.(documentDataModel, actions) ||
documentViewModel.resetByValidatedMetadataMutation?.(documentDataModel, actions)
);
if (!didResetIncrementally) {
documentViewModel.reset(documentDataModel);
}
@@ -157,26 +167,20 @@ function scheduleDocumentSelectionUpdate(
isSync: boolean
): void {
const { unitId, textRanges, trigger, noNeedSetTextRange, isEditing = true } = params;
if (noNeedSetTextRange || textRanges == null || trigger == null || isSync) {
if (noNeedSetTextRange || textRanges == null || textRanges.length === 0 || trigger == null || isSync) {
return;
}
queueMicrotask(() => {
const selectionTarget = { unitId, subUnitId: unitId };
const currentSelection = selectionManager.getSelectionInfo(selectionTarget);
if (currentSelection == null) {
selectionManager.replaceDocRanges(textRanges, selectionTarget, isEditing, params.options);
return;
}
const selectionTarget = { unitId, subUnitId: unitId };
const currentSelection = selectionManager.getSelectionInfo(selectionTarget);
if (currentSelection != null) {
const logicalTextRanges = textRanges.map((textRange, index) => ({
...textRange,
collapsed: textRange.startOffset === textRange.endOffset,
isActive: index === textRanges.length - 1,
}));
// The logical range advances with the mutation even if its new physical
// page has not been published yet. Keeping that intent in the model lets
// the render layer retry the same caret after foreground pagination.
// Advance logical intent with the mutation. Only its visual refresh is
// deferred; a later input or pointer selection must supersede this one.
selectionManager.replaceSelectionInfoWithoutRefresh({
...currentSelection,
textRanges: logicalTextRanges,
@@ -184,7 +188,17 @@ function scheduleDocumentSelectionUpdate(
isEditing,
options: params.options,
}, selectionTarget);
selectionManager.refreshSelection(selectionTarget, isEditing);
}
const updatedSelection = selectionManager.getSelectionInfo(selectionTarget);
queueMicrotask(() => {
if (selectionManager.getSelectionInfo(selectionTarget) !== updatedSelection) {
return;
}
if (updatedSelection == null) {
selectionManager.replaceDocRanges(textRanges, selectionTarget, isEditing, params.options);
} else {
selectionManager.refreshSelection(selectionTarget, isEditing);
}
});
}
@@ -55,6 +55,19 @@ const STRUCTURAL_DATA_STREAM_TOKENS = new Set<string>([
DataStreamTreeTokenType.BLOCK_END,
DataStreamTreeTokenType.CUSTOM_BLOCK,
]);
const NON_STRUCTURAL_ROOT_FIELDS = new Set<string>([
'disabled',
'documentStyle',
'drawings',
'drawingsOrder',
'lists',
'locale',
'resources',
'rev',
'settings',
'tableSource',
'title',
]);
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value != null && !Array.isArray(value);
}
@@ -114,7 +127,7 @@ function pathsEqual(left: JSONXPath, right: JSONXPath): boolean {
function isStructurePreservingJSONXEdit(actions: JSONXActions, expectedPath: JSONXPath): boolean {
const cursor = JSON1.type.readCursor(actions);
let hasTextEdit = false;
let hasComponent = false;
let isStructurePreserving = true;
cursor.traverse(null, (component) => {
@@ -122,22 +135,23 @@ function isStructurePreservingJSONXEdit(actions: JSONXActions, expectedPath: JSO
return;
}
const path = cursor.getPath();
hasComponent = true;
if (NON_STRUCTURAL_ROOT_FIELDS.has(String(path[0]))) {
return;
}
const componentKeys = Object.keys(component);
if (
component.et !== TextX.id ||
componentKeys.some((key) => key !== 'et' && key !== 'e') ||
!pathsEqual(cursor.getPath(), expectedPath)
) {
if (component.et !== TextX.id || componentKeys.some((key) => key !== 'et' && key !== 'e') || !pathsEqual(path, expectedPath)) {
isStructurePreserving = false;
return;
}
const edit: unknown = component.e;
hasTextEdit = true;
isStructurePreserving = isStructurePreservingTextXEdit(edit);
});
return hasTextEdit && isStructurePreserving;
return hasComponent && isStructurePreserving;
}
function getSegmentType(documentDataModel: DocumentDataModel, segmentId: string): 'body' | 'header' | 'footer' {
@@ -196,6 +196,7 @@ export function createTestBed(documentConfig?: IDocumentData): ITestBed {
with: <T>() => ({
getViewModel: () => ({
reset: () => undefined,
resetByValidatedMetadataMutation: () => false,
resetByValidatedTextMutation: () => false,
}),
}) as T,
+3
View File
@@ -87,6 +87,9 @@ export {
export type {
IDocLayoutCancelRequest,
IDocLayoutCreateSessionRequest,
IDocLayoutCustomRangePresentation,
IDocLayoutCustomRangePresentationContext,
IDocLayoutCustomRangePresentationEntry,
IDocLayoutDisposeMountRequest,
IDocLayoutDisposeSessionRequest,
IDocLayoutExecutor,
@@ -16,6 +16,7 @@
import {
createDocumentModelWithStyle,
CustomRangeType,
DocumentFlavor,
LocaleType,
ObjectRelativeFromH,
@@ -52,6 +53,117 @@ describe('DocsLayoutWorkerRuntime', () => {
vi.unstubAllGlobals();
});
it('applies custom range presentations to the matching body, header, and footer segments', async () => {
stubOffscreenCanvas();
const sourceModel = createDocumentModelWithStyle('Body\r', {});
sourceModel.updateDocumentStyle({ documentFlavor: DocumentFlavor.TRADITIONAL });
const snapshot = structuredClone(sourceModel.getSnapshot());
const createRange = (startIndex: number) => ({
startIndex,
endIndex: startIndex,
rangeId: 'shared-range',
rangeType: CustomRangeType.CUSTOM,
});
snapshot.body!.customRanges = [createRange(0)];
snapshot.headers = {
'header-1': {
headerId: 'header-1',
body: {
dataStream: 'Header\r\n',
paragraphs: [{ paragraphId: 'header-paragraph', startIndex: 6 }],
customRanges: [createRange(1)],
},
},
};
snapshot.footers = {
'footer-1': {
footerId: 'footer-1',
body: {
dataStream: 'Footer\r\n',
paragraphs: [{ paragraphId: 'footer-paragraph', startIndex: 6 }],
customRanges: [createRange(2)],
},
},
};
const runtime = new DocsLayoutWorkerRuntime();
const unitId = sourceModel.getUnitId();
await runtime.createSession({
unitId,
sessionEpoch: 1,
snapshot,
modelRevision: 0,
locale: LocaleType.EN_US,
localeData: {},
direction: 'ltr',
});
await runtime.startLayout({
unitId,
mountId: 'mount-1',
mountEpoch: 1,
viewportEpoch: 1,
metricsRevision: 1,
baseRevision: 0,
modelRevision: 0,
mutations: [],
customBlockViewports: {},
customRangePresentations: [
{ segmentId: '', rangeId: 'shared-range', presentation: { glyphWidthEm: 1 } },
{ segmentId: '', rangeId: 'shared-range', presentation: { glyphWidthEm: 99 } },
{ segmentId: 'header-1', rangeId: 'shared-range', presentation: { glyphWidthEm: 2 } },
{ segmentId: 'footer-1', rangeId: 'shared-range', presentation: { glyphWidthEm: 3 } },
],
reason: 'initial',
budgetMs: 0,
});
const dataModel = [...((runtime as unknown as {
_sessions: Map<string, { dataModel: ReturnType<typeof createDocumentModelWithStyle> }>;
})._sessions.values())][0].dataModel;
expect(dataModel.getBody()?.customRanges?.[0]).toMatchObject({ glyphWidthEm: 1 });
expect(dataModel.getSelfOrHeaderFooterModel('header-1')?.getBody()?.customRanges?.[0]).toMatchObject({ glyphWidthEm: 2 });
expect(dataModel.getSelfOrHeaderFooterModel('footer-1')?.getBody()?.customRanges?.[0]).toMatchObject({ glyphWidthEm: 3 });
});
it('applies many presentations with one bounded pass over each segment range list', () => {
const rangeCount = 100;
let indexedRangeReads = 0;
const ranges = new Proxy(Array.from({ length: rangeCount }, (_, index) => ({
startIndex: index,
endIndex: index,
rangeId: `range-${index}`,
rangeType: CustomRangeType.CUSTOM,
})), {
get(target, property, receiver) {
if (typeof property === 'string' && /^\d+$/.test(property)) {
indexedRangeReads++;
}
return Reflect.get(target, property, receiver);
},
});
const body = { dataStream: 'Body\r\n', customRanges: ranges };
const dataModel = {
getBody: () => body,
headerModelMap: new Map(),
footerModelMap: new Map(),
getSelfOrHeaderFooterModel: () => ({ getBody: () => body }),
};
const presentations = ranges.map((range, index) => ({
segmentId: '',
rangeId: range.rangeId,
presentation: { glyphWidthEm: index + 1 },
}));
indexedRangeReads = 0;
(new DocsLayoutWorkerRuntime() as unknown as {
_applyCustomRangePresentations(dataModel: unknown, presentations: unknown): void;
})._applyCustomRangePresentations(dataModel, presentations);
expect(indexedRangeReads).toBeLessThanOrEqual(rangeCount * 2);
expect(ranges[0]).toMatchObject({ glyphWidthEm: 1 });
expect(ranges[rangeCount - 1]).toMatchObject({ glyphWidthEm: rangeCount });
});
it('lays out a snapshot and enforces sequential model revisions', async () => {
stubOffscreenCanvas();
+39
View File
@@ -14,6 +14,7 @@
* limitations under the License.
*/
import type { ICustomRangeForInterceptor } from '@univerjs/core';
import type { IDocsCustomBlockRenderViewport } from '@univerjs/engine-render';
import type { IMessageProtocol } from '@univerjs/rpc';
import type { IDocLayoutCancelRequest, IDocLayoutCreateSessionRequest, IDocLayoutDisposeMountRequest, IDocLayoutDisposeSessionRequest, IDocLayoutMountIdentity, IDocLayoutPageRequest, IDocLayoutPageResult, IDocLayoutPerformanceMetrics, IDocLayoutStartRequest, IDocLayoutStartResult, IDocLayoutStepRequest, IDocLayoutStepResult } from '../services/doc-layout-executor.service';
@@ -157,6 +158,8 @@ export class DocsLayoutWorkerRuntime implements IDocsLayoutWorkerRuntime {
};
}
this._applyCustomRangePresentations(session.dataModel, request.customRangePresentations ?? []);
let mount = session.layoutSessions.get(request.mountId);
if (
mount != null && (
@@ -189,6 +192,7 @@ export class DocsLayoutWorkerRuntime implements IDocsLayoutWorkerRuntime {
mount.viewportEpoch = request.viewportEpoch;
}
const generation = mount.layoutSession.start({
waitForHyphenationPatterns: true,
reason: request.reason,
anchor: request.anchor,
priorityAnchor: request.priorityAnchor,
@@ -229,6 +233,41 @@ export class DocsLayoutWorkerRuntime implements IDocsLayoutWorkerRuntime {
return true;
}
private _applyCustomRangePresentations(
dataModel: DocumentDataModel,
presentations: IDocLayoutStartRequest['customRangePresentations']
): void {
const rangeMaps = new Map<string, Map<string, ICustomRangeForInterceptor>>();
const segmentModels = [
['', dataModel] as const,
...dataModel.headerModelMap,
...dataModel.footerModelMap,
];
for (const [segmentId, segmentModel] of segmentModels) {
const rangeMap = new Map<string, ICustomRangeForInterceptor>();
for (const range of segmentModel.getBody()?.customRanges ?? []) {
const workerRange = range as ICustomRangeForInterceptor;
delete workerRange.active;
delete workerRange.glyphAscentEm;
delete workerRange.glyphDescentEm;
delete workerRange.glyphWidthEm;
delete workerRange.show;
if (!rangeMap.has(range.rangeId)) {
rangeMap.set(range.rangeId, workerRange);
}
}
rangeMaps.set(segmentId, rangeMap);
}
for (const { segmentId, rangeId, presentation } of presentations ?? []) {
const rangeMap = rangeMaps.get(segmentId);
const range = rangeMap?.get(rangeId);
if (range != null) {
Object.assign(range, presentation);
rangeMap?.delete(rangeId);
}
}
}
async stepLayout(request: IDocLayoutStepRequest): Promise<IDocLayoutStepResult> {
const session = this._getSession(request.unitId);
const mount = this._getLayoutMount(session, request);
@@ -17,10 +17,9 @@
import type { DocumentDataModel, IDocumentData } from '@univerjs/core';
import type { IDocLayoutExecutor, IDocLayoutMountIdentity, IDocLayoutStartResult } from '../doc-layout-executor.service';
import {
CustomRangeType,
DocumentFlavor,
ICommandService,
IUniverInstanceService,
JSONX,
LifecycleService,
@@ -208,6 +207,7 @@ describe('DocLayoutExecutorService', () => {
modelRevision: 0,
mutations: [],
customBlockViewports: {},
customRangePresentations: [],
reason: 'initial',
budgetMs: 32,
});
@@ -445,6 +445,7 @@ describe('DocLayoutExecutorService', () => {
expect.objectContaining({ baseRevision: 1, modelRevision: 2 }),
],
customBlockViewports: {},
customRangePresentations: [],
reason: 'edit',
anchor: 5,
budgetMs: 32,
@@ -660,6 +661,85 @@ describe('DocLayoutExecutorService', () => {
unregister();
});
it('serializes custom range presentations for the body, headers, and footers without range id collisions', async () => {
const injector = univer.__getInjector();
const service = injector.get(DocLayoutExecutorService);
const executor = createExecutor();
service.register(executor);
service.registerCustomRangePresentationProvider((_unitId, range) => ({
glyphWidthEm: range.startIndex + 1,
}));
const documentData = createDocumentData('traditional-doc', DocumentFlavor.TRADITIONAL);
documentData.body!.customRanges = [
{
startIndex: 0,
endIndex: 0,
rangeId: 'shared-range',
rangeType: CustomRangeType.CUSTOM,
},
{
startIndex: 3,
endIndex: 3,
rangeId: 'shared-range',
rangeType: CustomRangeType.CUSTOM,
},
];
documentData.headers = {
'header-1': {
headerId: 'header-1',
body: {
dataStream: 'Header\r\n',
paragraphs: [{ paragraphId: 'header-paragraph', startIndex: 6 }],
customRanges: [{
startIndex: 1,
endIndex: 1,
rangeId: 'shared-range',
rangeType: CustomRangeType.CUSTOM,
}],
},
},
};
documentData.footers = {
'footer-1': {
footerId: 'footer-1',
body: {
dataStream: 'Footer\r\n',
paragraphs: [{ paragraphId: 'footer-paragraph', startIndex: 6 }],
customRanges: [{
startIndex: 2,
endIndex: 2,
rangeId: 'shared-range',
rangeType: CustomRangeType.CUSTOM,
}],
},
},
};
univer.createUnit<IDocumentData, DocumentDataModel>(UniverInstanceType.UNIVER_DOC, documentData);
await Promise.resolve();
await service.startLayout(createMountIdentity(), { reason: 'initial' }, 32);
expect(executor.startLayout).toHaveBeenCalledWith(expect.objectContaining({
customRangePresentations: [
{
segmentId: '',
rangeId: 'shared-range',
presentation: { glyphWidthEm: 1 },
},
{
segmentId: 'header-1',
rangeId: 'shared-range',
presentation: { glyphWidthEm: 2 },
},
{
segmentId: 'footer-1',
rangeId: 'shared-range',
presentation: { glyphWidthEm: 3 },
},
],
}));
});
it('skips queued layout starts that a newer edit superseded', async () => {
const injector = univer.__getInjector();
const service = injector.get(DocLayoutExecutorService);

Some files were not shown because too many files have changed in this diff Show More