mirror of
https://github.com/dream-num/univer.git
synced 2026-10-02 04:24:34 +08:00
perf(docs): keep large documents responsive (#7615)
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
|
||||
+273
-226
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
+279
-82
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
+22
@@ -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;
|
||||
|
||||
+119
@@ -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 = [
|
||||
|
||||
+44
-11
@@ -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;
|
||||
}
|
||||
+124
-39
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+64
-13
@@ -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 = {
|
||||
|
||||
+157
@@ -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) {
|
||||
|
||||
+94
-2
@@ -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>();
|
||||
|
||||
+95
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
+21
-4
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+4
-25
@@ -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);
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
+60
-1
@@ -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();
|
||||
}}
|
||||
|
||||
+7
-8
@@ -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,
|
||||
|
||||
+5
-9
@@ -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('');
|
||||
|
||||
+258
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
+156
@@ -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);
|
||||
});
|
||||
});
|
||||
+70
-30
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
+27
-4
@@ -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();
|
||||
|
||||
+470
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
+150
-14
@@ -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();
|
||||
|
||||
+167
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
+34
-11
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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('页面设置');
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,7 @@ import type enUS from './en-US';
|
||||
const locale: typeof enUS = {
|
||||
'docs-ui': {
|
||||
layout: {
|
||||
progress: 'جارٍ تنسيق المستند…',
|
||||
recovering: 'جارٍ استعادة تخطيط المستند…',
|
||||
},
|
||||
toolbar: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -19,6 +19,7 @@ import type enUS from './en-US';
|
||||
const locale: typeof enUS = {
|
||||
'docs-ui': {
|
||||
layout: {
|
||||
progress: 'در حال صفحهآرایی سند…',
|
||||
recovering: 'در حال بازیابی صفحهآرایی سند…',
|
||||
},
|
||||
toolbar: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -19,6 +19,7 @@ import type enUS from './en-US';
|
||||
const locale: typeof enUS = {
|
||||
'docs-ui': {
|
||||
layout: {
|
||||
progress: 'ドキュメントをレイアウトしています…',
|
||||
recovering: 'ドキュメントのレイアウトを復元しています…',
|
||||
},
|
||||
toolbar: {
|
||||
|
||||
@@ -19,6 +19,7 @@ import type enUS from './en-US';
|
||||
const locale: typeof enUS = {
|
||||
'docs-ui': {
|
||||
layout: {
|
||||
progress: '문서 레이아웃을 처리하는 중…',
|
||||
recovering: '문서 레이아웃을 복구하는 중…',
|
||||
},
|
||||
toolbar: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -19,6 +19,7 @@ import type enUS from './en-US';
|
||||
const locale: typeof enUS = {
|
||||
'docs-ui': {
|
||||
layout: {
|
||||
progress: 'Выполняется разметка документа…',
|
||||
recovering: 'Восстановление макета документа…',
|
||||
},
|
||||
toolbar: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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': '适中',
|
||||
|
||||
@@ -19,6 +19,7 @@ import type enUS from './en-US';
|
||||
const locale: typeof enUS = {
|
||||
'docs-ui': {
|
||||
layout: {
|
||||
progress: '正在排版文件…',
|
||||
recovering: '正在恢復文件排版…',
|
||||
},
|
||||
toolbar: {
|
||||
|
||||
@@ -19,6 +19,7 @@ import type enUS from './en-US';
|
||||
const locale: typeof enUS = {
|
||||
'docs-ui': {
|
||||
layout: {
|
||||
progress: '正在排版文件…',
|
||||
recovering: '正在復原文件排版…',
|
||||
},
|
||||
toolbar: {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
+61
-16
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
+47
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user