mirror of
https://github.com/dream-num/univer.git
synced 2026-10-02 04:24:34 +08:00
fix(docs): support native groups and behind-text drawing selection (#7701)
This commit is contained in:
@@ -24,6 +24,39 @@ import { getRichTextEditPath } from '../../utils';
|
||||
import { addDrawing, getCustomBlockIdsInSelections, removeDrawingReferences } from '../drawings';
|
||||
|
||||
describe('drawing build utils', () => {
|
||||
it('deletes nested group descendants when their only document anchor is removed', () => {
|
||||
const drawing = {
|
||||
unitId: 'doc-group',
|
||||
subUnitId: 'doc-group',
|
||||
drawingId: 'root',
|
||||
drawingType: DrawingTypeEnum.DRAWING_GROUP,
|
||||
layoutType: PositionedObjectLayoutType.INLINE,
|
||||
docTransform: {
|
||||
angle: 0,
|
||||
positionH: { relativeFrom: 0, posOffset: 0 },
|
||||
positionV: { relativeFrom: 0, posOffset: 0 },
|
||||
size: { width: 100, height: 100 },
|
||||
},
|
||||
};
|
||||
const doc = new DocumentDataModel({
|
||||
id: 'doc-group',
|
||||
body: { dataStream: '\b\r\n', customBlocks: [{ startIndex: 0, blockId: 'root' }] },
|
||||
drawings: {
|
||||
leaf: { ...drawing, drawingId: 'leaf', drawingType: DrawingTypeEnum.DRAWING_SHAPE, groupId: 'nested' },
|
||||
nested: { ...drawing, drawingId: 'nested', groupId: 'root' },
|
||||
root: drawing,
|
||||
unrelated: { ...drawing, drawingId: 'unrelated' },
|
||||
},
|
||||
drawingsOrder: ['root', 'nested', 'leaf', 'unrelated'],
|
||||
});
|
||||
const actions = removeDrawingReferences(doc.getSnapshot(), [{ startOffset: 0, endOffset: 1, collapsed: false }]);
|
||||
for (const action of actions) {
|
||||
doc.apply(action);
|
||||
}
|
||||
expect(Object.keys(doc.getDrawings()!)).toEqual(['unrelated']);
|
||||
expect(doc.getDrawingsOrder()).toEqual(['unrelated']);
|
||||
});
|
||||
|
||||
it('removes drawing references in reverse order for a structural text deletion', () => {
|
||||
const doc = new DocumentDataModel({
|
||||
id: 'doc-remove-drawings',
|
||||
@@ -74,7 +107,9 @@ describe('drawing build utils', () => {
|
||||
for (const action of actions.slice(1)) {
|
||||
composedActions = JSONX.compose(composedActions, action);
|
||||
}
|
||||
if (!composedActions) throw new Error('Expected drawing removal actions');
|
||||
if (!composedActions) {
|
||||
throw new Error('Expected drawing removal actions');
|
||||
}
|
||||
doc.apply(composedActions);
|
||||
|
||||
expect(doc.getDrawings()).toEqual({});
|
||||
|
||||
@@ -70,8 +70,17 @@ export function removeDrawingReferences(
|
||||
const prefix = footnote ? ['notes', segmentId] : [];
|
||||
const drawings = source.drawings ?? {};
|
||||
const drawingOrder = source.drawingsOrder ?? [];
|
||||
const blockIds = [...new Set(getCustomBlockIdsInSelections(body, selections))]
|
||||
.sort((left, right) => drawingOrder.indexOf(right) - drawingOrder.indexOf(left));
|
||||
const removedIds = new Set(getCustomBlockIdsInSelections(body, selections));
|
||||
let previousSize = -1;
|
||||
while (previousSize !== removedIds.size) {
|
||||
previousSize = removedIds.size;
|
||||
for (const drawing of Object.values(drawings)) {
|
||||
if (drawing.groupId && removedIds.has(drawing.groupId)) {
|
||||
removedIds.add(drawing.drawingId);
|
||||
}
|
||||
}
|
||||
}
|
||||
const blockIds = [...removedIds].sort((left, right) => drawingOrder.indexOf(right) - drawingOrder.indexOf(left));
|
||||
const jsonX = JSONX.getInstance();
|
||||
const actions: JSONXActions[] = [];
|
||||
|
||||
|
||||
@@ -67,8 +67,10 @@ import { DocDrawingUpdateRenderController } from '../../../controllers/render-co
|
||||
import { DocRefreshDrawingsService } from '../../../services/doc-refresh-drawings.service';
|
||||
import { ClearDocDrawingTransformerOperation } from '../../operations/clear-drawing-transformer.operation';
|
||||
import { DeleteDocDrawingsCommand } from '../delete-doc-drawing.command';
|
||||
import { GroupDocDrawingCommand } from '../group-doc-drawing.command';
|
||||
import { InsertDocImageCommand } from '../insert-image.command';
|
||||
import { MoveDocDrawingsCommand } from '../move-drawings.command';
|
||||
import { UngroupDocDrawingCommand } from '../ungroup-doc-drawing.command';
|
||||
import {
|
||||
IMoveInlineDrawingCommand,
|
||||
ITransformNonInlineDrawingCommand,
|
||||
@@ -352,6 +354,8 @@ function setupDrawingTestBed(docData: IDocumentData, dependencies: Dependency[]
|
||||
|
||||
const commandService = get(ICommandService);
|
||||
[
|
||||
GroupDocDrawingCommand,
|
||||
UngroupDocDrawingCommand,
|
||||
DeleteDocDrawingsCommand,
|
||||
MoveDocDrawingsCommand,
|
||||
ClearDocDrawingTransformerOperation,
|
||||
@@ -391,6 +395,84 @@ describe('docs drawing commands integration', () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('groups and ungroups native objects with document anchors and symmetric undo/redo', async () => {
|
||||
const data = createBaseDocData();
|
||||
data.body = {
|
||||
dataStream: 'A\bB\bC\r\n',
|
||||
customBlocks: [{ startIndex: 1, blockId: 'a' }, { startIndex: 3, blockId: 'b' }],
|
||||
paragraphs: [{ startIndex: 5, paragraphId: 'group-paragraph' }],
|
||||
};
|
||||
const drawings = [10, 80].map((left, index) => ({
|
||||
unitId: data.id,
|
||||
subUnitId: data.id,
|
||||
drawingId: index === 0 ? 'a' : 'b',
|
||||
drawingType: DrawingTypeEnum.DRAWING_SHAPE,
|
||||
title: `Rich text shape ${index}`,
|
||||
layoutType: PositionedObjectLayoutType.WRAP_NONE,
|
||||
transform: { left, top: 20, width: 50, height: 40, angle: 0 },
|
||||
docTransform: {
|
||||
positionH: { relativeFrom: ObjectRelativeFromH.PAGE, posOffset: left },
|
||||
positionV: { relativeFrom: ObjectRelativeFromV.PAGE, posOffset: 20 },
|
||||
size: { width: 50, height: 40 },
|
||||
angle: 0,
|
||||
},
|
||||
}));
|
||||
data.drawings = Object.fromEntries(drawings.map((drawing) => [drawing.drawingId, { ...drawing, unitId: 'imported-unit', subUnitId: 'imported-unit' }]));
|
||||
data.drawings.b.drawingType = DrawingTypeEnum.DRAWING_GROUP;
|
||||
data.drawings.b.groupBaseBound = { left: 0, top: 0, width: 50, height: 40 };
|
||||
data.drawings.c = { ...data.drawings.a, drawingId: 'c', groupId: 'b' };
|
||||
data.drawingsOrder = ['a', 'b', 'c'];
|
||||
const testBed = setupDrawingTestBed(data);
|
||||
const { commandService, get, univer, drawingManagerService } = testBed;
|
||||
const model = get(IUniverInstanceService).getUnit<DocumentDataModel>(data.id)!;
|
||||
const before = structuredClone(model.getSnapshot());
|
||||
const parent = {
|
||||
unitId: data.id,
|
||||
subUnitId: data.id,
|
||||
drawingId: 'group',
|
||||
drawingType: DrawingTypeEnum.DRAWING_GROUP,
|
||||
transform: { left: 10, top: 20, width: 120, height: 40, angle: 0 },
|
||||
};
|
||||
try {
|
||||
expect(await commandService.executeCommand(GroupDocDrawingCommand.id, [{ parent, children: drawings }])).toBe(true);
|
||||
expect(model.getBody()!.dataStream).toBe('A\bBC\r\n');
|
||||
expect(model.getBody()!.customBlocks).toEqual([{ startIndex: 1, blockId: 'group' }]);
|
||||
expect(model.getSnapshot().drawings!.b.groupId).toBe('group');
|
||||
expect(model.getSnapshot().drawings!.b.docTransform.positionH.posOffset).toBe(70);
|
||||
expect(model.getSnapshot().drawings!.b.title).toBe('Rich text shape 1');
|
||||
expect(drawingManagerService.getDrawingByParam(drawings[1])!.groupId).toBe('group');
|
||||
await awaitTime(350);
|
||||
expect(await commandService.executeCommand(UndoCommand.id)).toBe(true);
|
||||
expect(model.getBody()).toMatchObject(before.body!);
|
||||
expect(model.getSnapshot().drawings).toEqual(before.drawings);
|
||||
expect(await commandService.executeCommand(RedoCommand.id)).toBe(true);
|
||||
const grouped = structuredClone(model.getSnapshot());
|
||||
expect(await commandService.executeCommand(UngroupDocDrawingCommand.id, [{ parent, children: drawings }])).toBe(true);
|
||||
expect(model.getBody()!.customBlocks).toEqual([{ startIndex: 1, blockId: 'a' }, { startIndex: 2, blockId: 'b' }]);
|
||||
expect(model.getSnapshot().drawings!.group).toBeUndefined();
|
||||
expect(model.getSnapshot().drawings!.b.groupId).toBeUndefined();
|
||||
expect(model.getSnapshot().drawings!.b.docTransform.positionH.posOffset).toBe(80);
|
||||
expect(drawingManagerService.getDrawingByParam({ unitId: data.id, subUnitId: data.id, drawingId: 'c' })?.groupId).toBe('b');
|
||||
await awaitTime(350);
|
||||
expect(await commandService.executeCommand(UndoCommand.id)).toBe(true);
|
||||
expect(model.getBody()).toMatchObject(grouped.body!);
|
||||
expect(model.getSnapshot().drawings).toEqual(grouped.drawings);
|
||||
expect(await commandService.executeCommand(RedoCommand.id)).toBe(true);
|
||||
expect(model.getSnapshot().drawings!.b.groupId).toBeUndefined();
|
||||
await commandService.executeCommand(UndoCommand.id);
|
||||
expect(await commandService.executeCommand(RemoveDocDrawingCommand.id, { unitId: data.id, drawings: [parent] })).toBe(true);
|
||||
expect(model.getSnapshot().drawings).toEqual({});
|
||||
expect(model.getSnapshot().drawingsOrder).toEqual([]);
|
||||
await awaitTime(350);
|
||||
expect(await commandService.executeCommand(UndoCommand.id)).toBe(true);
|
||||
expect(model.getSnapshot().drawings).toEqual(grouped.drawings);
|
||||
expect(drawingManagerService.getDrawingByParam(drawings[1])!.unitId).toBe(data.id);
|
||||
expect(drawingManagerService.getDrawingByParam({ unitId: data.id, subUnitId: data.id, drawingId: 'c' })?.groupId).toBe('b');
|
||||
} finally {
|
||||
univer.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it('inserts a drawing through the real mutation chain and synchronizes drawing services', async () => {
|
||||
const testBed = setupDrawingTestBed(createBaseDocData());
|
||||
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
/**
|
||||
* 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,
|
||||
IAccessor,
|
||||
IDocDrawingBase,
|
||||
IDocumentBody,
|
||||
ITransformState,
|
||||
JSONXActions,
|
||||
} from '@univerjs/core';
|
||||
import type { IRichTextEditingMutationParams } from '@univerjs/docs';
|
||||
import type { IDrawingGroupUpdateParam } from '@univerjs/drawing';
|
||||
import {
|
||||
DrawingTypeEnum,
|
||||
getRichTextEditPath,
|
||||
ICommandService,
|
||||
IUniverInstanceService,
|
||||
JSONX,
|
||||
ObjectRelativeFromH,
|
||||
ObjectRelativeFromV,
|
||||
PositionedObjectLayoutType,
|
||||
TextX,
|
||||
TextXActionType,
|
||||
UniverInstanceType,
|
||||
} from '@univerjs/core';
|
||||
import { RichTextEditingMutation } from '@univerjs/docs';
|
||||
import { collectDocDrawings } from '@univerjs/docs-drawing';
|
||||
|
||||
function findAnchor(model: DocumentDataModel, drawingId: string): { segmentId: string; index: number } | undefined {
|
||||
const snapshot = model.getSnapshot();
|
||||
const bodies: Array<[string, IDocumentBody | undefined]> = [
|
||||
['', snapshot.body],
|
||||
...Object.entries(snapshot.headers ?? {}).map(([id, header]): [string, IDocumentBody] => [id, header.body]),
|
||||
...Object.entries(snapshot.footers ?? {}).map(([id, footer]): [string, IDocumentBody] => [id, footer.body]),
|
||||
...Object.entries(snapshot.notes ?? {}).map(([id, note]): [string, IDocumentBody] => [id, note.body]),
|
||||
];
|
||||
for (const [segmentId, body] of bodies) {
|
||||
const block = body?.customBlocks?.find((block) => block.blockId === drawingId);
|
||||
if (block) {
|
||||
return { segmentId, index: block.startIndex };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function docTransformFromScene(
|
||||
transform: ITransformState,
|
||||
reference: IDocDrawingBase,
|
||||
referenceTransform: ITransformState
|
||||
): IDocDrawingBase['docTransform'] {
|
||||
const { positionH, positionV } = reference.docTransform;
|
||||
return {
|
||||
size: { width: transform.width ?? 0, height: transform.height ?? 0 },
|
||||
positionH: {
|
||||
relativeFrom: positionH.relativeFrom,
|
||||
posOffset: (positionH.posOffset ?? 0) + (transform.left ?? 0) - (referenceTransform.left ?? 0),
|
||||
},
|
||||
positionV: {
|
||||
relativeFrom: positionV.relativeFrom,
|
||||
posOffset: (positionV.posOffset ?? 0) + (transform.top ?? 0) - (referenceTransform.top ?? 0),
|
||||
},
|
||||
angle: transform.angle ?? 0,
|
||||
flipX: transform.flipX,
|
||||
flipY: transform.flipY,
|
||||
};
|
||||
}
|
||||
|
||||
interface IGroupChanges {
|
||||
changes: Map<string, IDocDrawingBase | undefined>;
|
||||
replacements: Map<number, string[]>;
|
||||
}
|
||||
|
||||
type IDrawingAnchors = Array<{ segmentId: string; index: number }>;
|
||||
|
||||
function ungroupDrawings(
|
||||
{ parent, children }: IDrawingGroupUpdateParam,
|
||||
drawings: Record<string, IDocDrawingBase>,
|
||||
anchors: IDrawingAnchors,
|
||||
{ changes, replacements }: IGroupChanges
|
||||
): boolean {
|
||||
const anchor = anchors[0];
|
||||
const group = drawings[parent.drawingId];
|
||||
const childIds = new Set(children.map((child) => child.drawingId));
|
||||
if (!group || group.groupId || group.drawingType !== DrawingTypeEnum.DRAWING_GROUP ||
|
||||
Object.values(drawings).some((drawing) => drawing.groupId === parent.drawingId && !childIds.has(drawing.drawingId))) {
|
||||
return false;
|
||||
}
|
||||
replacements.set(anchor.index, children.map((child) => child.drawingId));
|
||||
changes.set(parent.drawingId, undefined);
|
||||
for (const child of children) {
|
||||
if (!child.transform || drawings[child.drawingId].groupId !== parent.drawingId) {
|
||||
return false;
|
||||
}
|
||||
const drawing = { ...drawings[child.drawingId] };
|
||||
delete drawing.groupId;
|
||||
drawing.layoutType = PositionedObjectLayoutType.WRAP_NONE;
|
||||
drawing.behindDoc = group.behindDoc;
|
||||
drawing.docTransform = docTransformFromScene(child.transform, group, parent.transform!);
|
||||
changes.set(child.drawingId, drawing);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function groupDrawings(
|
||||
{ parent, children }: IDrawingGroupUpdateParam,
|
||||
drawings: Record<string, IDocDrawingBase>,
|
||||
anchors: IDrawingAnchors,
|
||||
{ changes, replacements }: IGroupChanges
|
||||
): boolean {
|
||||
if (children.length < 2 || drawings[parent.drawingId] || children.some((child) => drawings[child.drawingId].groupId || !child.transform)) {
|
||||
return false;
|
||||
}
|
||||
const firstIndex = Math.min(...anchors.map((item) => item!.index));
|
||||
const firstChild = children[anchors.findIndex((item) => item!.index === firstIndex)];
|
||||
const reference = drawings[firstChild.drawingId];
|
||||
const transform = parent.transform!;
|
||||
const group: IDocDrawingBase = {
|
||||
...parent,
|
||||
drawingType: DrawingTypeEnum.DRAWING_GROUP,
|
||||
layoutType: reference.layoutType,
|
||||
behindDoc: reference.behindDoc,
|
||||
layoutInCell: reference.layoutInCell,
|
||||
allowOverlap: reference.allowOverlap,
|
||||
wrapText: reference.wrapText,
|
||||
distL: reference.distL,
|
||||
distR: reference.distR,
|
||||
distT: reference.distT,
|
||||
distB: reference.distB,
|
||||
docTransform: docTransformFromScene(transform, reference, firstChild.transform!),
|
||||
groupBaseBound: { left: 0, top: 0, width: transform.width ?? 0, height: transform.height ?? 0 },
|
||||
};
|
||||
changes.set(parent.drawingId, group);
|
||||
for (let index = 0; index < children.length; index++) {
|
||||
const child = children[index];
|
||||
replacements.set(anchors[index]!.index, anchors[index]!.index === firstIndex ? [parent.drawingId] : []);
|
||||
const localTransform = docTransformFromScene(child.transform!, group, transform);
|
||||
localTransform.positionH = { relativeFrom: ObjectRelativeFromH.PAGE, posOffset: (child.transform!.left ?? 0) - (transform.left ?? 0) };
|
||||
localTransform.positionV = { relativeFrom: ObjectRelativeFromV.PAGE, posOffset: (child.transform!.top ?? 0) - (transform.top ?? 0) };
|
||||
changes.set(child.drawingId, { ...drawings[child.drawingId], groupId: parent.drawingId, docTransform: localTransform });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function buildDrawingChanges(
|
||||
drawings: Record<string, IDocDrawingBase>,
|
||||
prefix: string[],
|
||||
changes: IGroupChanges['changes']
|
||||
): JSONXActions[] {
|
||||
const jsonX = JSONX.getInstance();
|
||||
const actions: JSONXActions[] = [];
|
||||
for (const [id, drawing] of changes) {
|
||||
const previous = drawings[id];
|
||||
const path = [...prefix, 'drawings', id];
|
||||
if (!drawing) {
|
||||
actions.push(jsonX.removeOp(path, previous)!);
|
||||
} else if (!previous) {
|
||||
actions.push(jsonX.insertOp(path, drawing)!);
|
||||
} else {
|
||||
for (const key of ['groupId', 'docTransform', 'layoutType', 'behindDoc'] as const) {
|
||||
if (drawing[key] === previous[key]) {
|
||||
continue;
|
||||
}
|
||||
if (drawing[key] === undefined) {
|
||||
actions.push(jsonX.removeOp([...path, key], previous[key])!);
|
||||
} else if (previous[key] === undefined) {
|
||||
actions.push(jsonX.insertOp([...path, key], drawing[key])!);
|
||||
} else {
|
||||
actions.push(jsonX.replaceOp([...path, key], previous[key], drawing[key])!);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return actions;
|
||||
}
|
||||
|
||||
function buildGroupActions(
|
||||
model: DocumentDataModel,
|
||||
segmentId: string,
|
||||
params: IDrawingGroupUpdateParam[],
|
||||
ungroup: boolean,
|
||||
{ changes, replacements }: IGroupChanges
|
||||
): JSONXActions[] {
|
||||
const snapshot = model.getSnapshot();
|
||||
const { drawings } = collectDocDrawings(snapshot);
|
||||
const source = snapshot.notes?.[segmentId!] ?? snapshot;
|
||||
const prefix = snapshot.notes?.[segmentId!] ? ['notes', segmentId!] : [];
|
||||
const textX = new TextX();
|
||||
let cursor = 0;
|
||||
for (const [index, ids] of [...replacements].sort(([left], [right]) => left - right)) {
|
||||
if (index > cursor) {
|
||||
textX.push({ t: TextXActionType.RETAIN, len: index - cursor });
|
||||
}
|
||||
textX.push({ t: TextXActionType.DELETE, len: 1 });
|
||||
if (ids.length > 0) {
|
||||
textX.push({
|
||||
t: TextXActionType.INSERT,
|
||||
len: ids.length,
|
||||
body: { dataStream: '\b'.repeat(ids.length), customBlocks: ids.map((blockId, startIndex) => ({ blockId, startIndex })) },
|
||||
});
|
||||
}
|
||||
cursor = index + 1;
|
||||
}
|
||||
const jsonX = JSONX.getInstance();
|
||||
const actions: JSONXActions[] = [jsonX.editOp(textX.serialize(), getRichTextEditPath(model, segmentId))!];
|
||||
actions.push(...buildDrawingChanges(drawings, prefix, changes));
|
||||
const previousOrder = source.drawingsOrder ?? Object.keys(source.drawings ?? {});
|
||||
const order = previousOrder.filter((id) => !changes.has(id) || changes.get(id));
|
||||
for (const { parent, children } of params) {
|
||||
if (!ungroup) {
|
||||
order.splice(Math.max(0, order.indexOf(children[0].drawingId)), 0, parent.drawingId);
|
||||
}
|
||||
}
|
||||
actions.push(source.drawingsOrder
|
||||
? jsonX.replaceOp([...prefix, 'drawingsOrder'], previousOrder, order)!
|
||||
: jsonX.insertOp([...prefix, 'drawingsOrder'], order)!);
|
||||
return actions;
|
||||
}
|
||||
|
||||
/** Change drawing ownership and its text anchors in one undoable document mutation. */
|
||||
export function changeDocDrawingGroups(
|
||||
accessor: IAccessor,
|
||||
params: IDrawingGroupUpdateParam[] | undefined,
|
||||
ungroup: boolean
|
||||
): boolean {
|
||||
if (!params?.length) {
|
||||
return false;
|
||||
}
|
||||
const unitId = params[0].parent.unitId;
|
||||
const model = accessor.get(IUniverInstanceService).getUnit<DocumentDataModel>(unitId, UniverInstanceType.UNIVER_DOC);
|
||||
if (!model || params.some(({ parent, children }) => parent.unitId !== unitId || children.some((child) => child.unitId !== unitId))) {
|
||||
return false;
|
||||
}
|
||||
const snapshot = model.getSnapshot();
|
||||
const { drawings } = collectDocDrawings(snapshot);
|
||||
const changes = new Map<string, IDocDrawingBase | undefined>();
|
||||
const replacements = new Map<number, string[]>();
|
||||
let segmentId: string | undefined;
|
||||
for (const { parent, children } of params) {
|
||||
const anchors = (ungroup ? [parent] : children).map(({ drawingId }) => findAnchor(model, drawingId));
|
||||
if (!children.length || anchors.some((anchor) => !anchor) || !parent.transform) {
|
||||
return false;
|
||||
}
|
||||
const anchor = anchors[0]!;
|
||||
segmentId ??= anchor.segmentId;
|
||||
if (anchors.some((item) => item!.segmentId !== segmentId) || children.some((child) => !drawings[child.drawingId] || changes.has(child.drawingId))) {
|
||||
return false;
|
||||
}
|
||||
const planned = ungroup
|
||||
? ungroupDrawings({ parent, children }, drawings, anchors as IDrawingAnchors, { changes, replacements })
|
||||
: groupDrawings({ parent, children }, drawings, anchors as IDrawingAnchors, { changes, replacements });
|
||||
if (!planned) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const actions = buildGroupActions(model, segmentId!, params, ungroup, { changes, replacements });
|
||||
return Boolean(accessor.get(ICommandService).syncExecuteCommand<IRichTextEditingMutationParams>(RichTextEditingMutation.id, {
|
||||
unitId,
|
||||
segmentId,
|
||||
actions: actions.reduce((result, action) => JSONX.compose(result, action), null as JSONXActions),
|
||||
textRanges: [],
|
||||
}));
|
||||
}
|
||||
@@ -14,55 +14,13 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { IAccessor, ICommand } from '@univerjs/core';
|
||||
import type { ICommand } from '@univerjs/core';
|
||||
import type { IDrawingGroupUpdateParam } from '@univerjs/drawing';
|
||||
import { CommandType } from '@univerjs/core';
|
||||
// import { IDocDrawingService } from '@univerjs/docs-drawing';
|
||||
import { changeDocDrawingGroups } from './doc-drawing-group';
|
||||
|
||||
/**
|
||||
* The command to insert new defined name
|
||||
*/
|
||||
export const GroupDocDrawingCommand: ICommand = {
|
||||
export const GroupDocDrawingCommand: ICommand<IDrawingGroupUpdateParam[]> = {
|
||||
id: 'doc.command.group-doc-image',
|
||||
type: CommandType.COMMAND,
|
||||
handler: (accessor: IAccessor, params?: IDrawingGroupUpdateParam[]) => {
|
||||
// const docDrawingService = accessor.get(IDocDrawingService);
|
||||
|
||||
// if (!params) return false;
|
||||
|
||||
// // if the subunit is not a doc type, return false
|
||||
|
||||
// const unitIds: string[] = [];
|
||||
// params.forEach(({ parent, children }) => {
|
||||
// unitIds.push(parent.unitId);
|
||||
// children.forEach((child) => {
|
||||
// unitIds.push(child.unitId);
|
||||
// });
|
||||
// });
|
||||
|
||||
// execute do mutations and add undo mutations to undo stack if completed
|
||||
// const jsonOp = docDrawingService.getGroupDrawingOp(params) as IDrawingJsonUndo1;
|
||||
|
||||
// const { unitId, subUnitId, undo, redo, objects } = jsonOp;
|
||||
|
||||
// const result = commandService.syncExecuteCommand(SetDocDrawingApplyMutation.id, { op: redo, unitId, subUnitId, objects, type: DocDrawingApplyType.GROUP });
|
||||
|
||||
// if (result) {
|
||||
// undoRedoService.pushUndoRedo({
|
||||
// unitID: unitId,
|
||||
// undoMutations: [
|
||||
// { id: SetDocDrawingApplyMutation.id, params: { op: undo, unitId, subUnitId, objects: groupToUngroup(objects as IDrawingGroupUpdateParam[]), type: DocDrawingApplyType.UNGROUP } },
|
||||
// { id: ClearDocDrawingTransformerOperation.id, params: unitIds },
|
||||
// ],
|
||||
// redoMutations: [
|
||||
// { id: SetDocDrawingApplyMutation.id, params: { op: redo, unitId, subUnitId, objects, type: DocDrawingApplyType.GROUP } },
|
||||
// { id: ClearDocDrawingTransformerOperation.id, params: unitIds },
|
||||
// ],
|
||||
// });
|
||||
|
||||
// return true;
|
||||
// }
|
||||
|
||||
return false;
|
||||
},
|
||||
handler: (accessor, params) => changeDocDrawingGroups(accessor, params, false),
|
||||
};
|
||||
|
||||
@@ -14,53 +14,13 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { IAccessor, ICommand } from '@univerjs/core';
|
||||
import type { IDrawingGroupUpdateParam, IDrawingJsonUndo1 } from '@univerjs/drawing';
|
||||
import type { ICommand } from '@univerjs/core';
|
||||
import type { IDrawingGroupUpdateParam } from '@univerjs/drawing';
|
||||
import { CommandType } from '@univerjs/core';
|
||||
import { IDocDrawingService } from '@univerjs/docs-drawing';
|
||||
import { changeDocDrawingGroups } from './doc-drawing-group';
|
||||
|
||||
/**
|
||||
* The command to insert new defined name
|
||||
*/
|
||||
export const UngroupDocDrawingCommand: ICommand = {
|
||||
export const UngroupDocDrawingCommand: ICommand<IDrawingGroupUpdateParam[]> = {
|
||||
id: 'doc.command.ungroup-doc-image',
|
||||
type: CommandType.COMMAND,
|
||||
handler: (accessor: IAccessor, params?: IDrawingGroupUpdateParam[]) => {
|
||||
const docDrawingService = accessor.get(IDocDrawingService);
|
||||
|
||||
if (!params) return false;
|
||||
|
||||
const unitIds: string[] = [];
|
||||
params.forEach(({ parent, children }) => {
|
||||
unitIds.push(parent.unitId);
|
||||
children.forEach((child) => {
|
||||
unitIds.push(child.unitId);
|
||||
});
|
||||
});
|
||||
|
||||
// execute do mutations and add undo mutations to undo stack if completed
|
||||
const jsonOp = docDrawingService.getUngroupDrawingOp(params) as IDrawingJsonUndo1;
|
||||
|
||||
const { unitId, subUnitId, undo, redo, objects } = jsonOp;
|
||||
|
||||
// const result = commandService.syncExecuteCommand(SetDocDrawingApplyMutation.id, { op: redo, unitId, subUnitId, objects, type: DocDrawingApplyType.UNGROUP });
|
||||
|
||||
// if (result) {
|
||||
// undoRedoService.pushUndoRedo({
|
||||
// unitID: unitId,
|
||||
// undoMutations: [
|
||||
// { id: SetDocDrawingApplyMutation.id, params: { op: undo, unitId, subUnitId, objects: ungroupToGroup(objects as IDrawingGroupUpdateParam[]), type: DocDrawingApplyType.GROUP } },
|
||||
// { id: ClearDocDrawingTransformerOperation.id, params: unitIds },
|
||||
// ],
|
||||
// redoMutations: [
|
||||
// { id: SetDocDrawingApplyMutation.id, params: { op: redo, unitId, subUnitId, objects, type: DocDrawingApplyType.UNGROUP } },
|
||||
// { id: ClearDocDrawingTransformerOperation.id, params: unitIds },
|
||||
// ],
|
||||
// });
|
||||
|
||||
// return true;
|
||||
// }
|
||||
|
||||
return false;
|
||||
},
|
||||
handler: (accessor, params) => changeDocDrawingGroups(accessor, params, true),
|
||||
};
|
||||
|
||||
@@ -56,47 +56,27 @@ interface IAddOrRemoveDrawing {
|
||||
drawing?: IDocDrawing;
|
||||
}
|
||||
|
||||
// Check whether drawings are added or deleted from the mutation and obtain the drawing ID.
|
||||
// eslint-disable-next-line complexity
|
||||
// Only whole drawing entries add/remove render objects; field edits are synchronized after the mutation.
|
||||
function getAddOrRemoveDrawings(actions: JSONXActions): Nullable<IAddOrRemoveDrawing[]> {
|
||||
if (JSONX.isNoop(actions) || !Array.isArray(actions)) {
|
||||
if (JSONX.isNoop(actions)) {
|
||||
return null;
|
||||
}
|
||||
const drawingsOp = actions.find((action) => Array.isArray(action) && action?.[0] === 'drawings');
|
||||
|
||||
if (drawingsOp == null || !Array.isArray(drawingsOp) || drawingsOp.length < 3) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof drawingsOp[1] === 'string' && typeof drawingsOp[2] !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Array.isArray(drawingsOp[1]) && typeof drawingsOp[1][1] !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const drawings: IAddOrRemoveDrawing[] = [];
|
||||
|
||||
if (Array.isArray(drawingsOp?.[1])) {
|
||||
for (const op of drawingsOp) {
|
||||
if (Array.isArray(op)) {
|
||||
drawings.push({
|
||||
type: (op?.[1] as any)?.i ? 'add' : 'remove',
|
||||
drawingId: op?.[0] as string,
|
||||
drawing: (op?.[1] as any)?.i,
|
||||
});
|
||||
}
|
||||
const cursor = JSON1.type.readCursor(actions);
|
||||
cursor.traverse(null, () => {
|
||||
const path = cursor.getPath();
|
||||
if (path.length !== 2 || path[0] !== 'drawings' || typeof path[1] !== 'string') {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
drawings.push({
|
||||
type: (drawingsOp[2] as any)?.i ? 'add' : 'remove',
|
||||
drawingId: drawingsOp[1] as string,
|
||||
drawing: (drawingsOp[2] as any)?.i,
|
||||
});
|
||||
}
|
||||
|
||||
return drawings;
|
||||
const component = cursor.getComponent();
|
||||
if (component?.r !== undefined) {
|
||||
drawings.push({ type: 'remove', drawingId: path[1] });
|
||||
}
|
||||
if (component?.i !== undefined) {
|
||||
drawings.push({ type: 'add', drawingId: path[1], drawing: component.i as IDocDrawing });
|
||||
}
|
||||
});
|
||||
return drawings.length ? drawings : null;
|
||||
}
|
||||
|
||||
// ReOrderedActions data like bellow:
|
||||
@@ -386,7 +366,9 @@ export class DocDrawingAddRemoveController extends Disposable {
|
||||
const drawingManagerService = this._drawingManagerService;
|
||||
const docDrawingService = this._docDrawingService;
|
||||
|
||||
const jsonOp = this._docDrawingService.getBatchAddOp(drawings) as IDrawingJsonUndo1;
|
||||
const jsonOp = this._docDrawingService.getBatchAddOp(
|
||||
drawings.map((drawing) => ({ ...drawing, unitId, subUnitId: unitId }))
|
||||
) as IDrawingJsonUndo1;
|
||||
|
||||
const { subUnitId, redo: op, objects } = jsonOp;
|
||||
|
||||
@@ -400,8 +382,13 @@ export class DocDrawingAddRemoveController extends Disposable {
|
||||
private _removeDrawings(unitId: string, drawingIds: string[]) {
|
||||
const drawingManagerService = this._drawingManagerService;
|
||||
const docDrawingService = this._docDrawingService;
|
||||
// An earlier group removal in this mutation may already have removed these descendants.
|
||||
const existingDrawingIds = drawingIds.filter((drawingId) => docDrawingService.getDrawingByParam({ unitId, subUnitId: unitId, drawingId }));
|
||||
if (existingDrawingIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const jsonOp = this._docDrawingService.getBatchRemoveOp(drawingIds.map((drawingId) => {
|
||||
const jsonOp = this._docDrawingService.getBatchRemoveOp(existingDrawingIds.map((drawingId) => {
|
||||
return {
|
||||
unitId,
|
||||
subUnitId: unitId,
|
||||
@@ -482,10 +469,16 @@ export class DocDrawingAddRemoveController extends Disposable {
|
||||
}
|
||||
|
||||
const { drawings, drawingsOrder } = collectDocDrawings(documentDataModel.getSnapshot());
|
||||
const drawingData = drawings as IDrawingMapItemData<IDocDrawing>;
|
||||
const drawingData: IDrawingMapItemData<IDocDrawing> = Object.fromEntries(
|
||||
Object.entries(drawings).map(([id, drawing]) => [id, { ...drawing, unitId, subUnitId: unitId }])
|
||||
);
|
||||
const previousDrawings = this._docDrawingService.getDrawingData(unitId, unitId);
|
||||
const previousRendered = this._drawingManagerService.getDrawingData(unitId, unitId);
|
||||
// Removing an outer group also removes its render descendants. Ungrouping retains them in the document.
|
||||
const missingIds = Object.keys(drawingData).filter((id) => !previousRendered[id]);
|
||||
const synchronizedDrawingIds = [...new Set([...drawingIds, ...missingIds])];
|
||||
const orderChanged = drawingsOrder !== this._docDrawingService.getDrawingOrder(unitId, unitId) ||
|
||||
drawingIds.some((drawingId) => {
|
||||
synchronizedDrawingIds.some((drawingId) => {
|
||||
const previous = previousDrawings[drawingId];
|
||||
const current = drawingData[drawingId];
|
||||
const wasBehind = previous?.layoutType === PositionedObjectLayoutType.WRAP_NONE &&
|
||||
@@ -495,8 +488,12 @@ export class DocDrawingAddRemoveController extends Disposable {
|
||||
return wasBehind !== isBehind;
|
||||
});
|
||||
|
||||
const hierarchyChanged = synchronizedDrawingIds.some((id) => previousDrawings[id]?.groupId !== drawingData[id]?.groupId);
|
||||
if (hierarchyChanged) {
|
||||
this._drawingManagerService.removeNotification(Object.values(this._drawingManagerService.getDrawingData(unitId, unitId)));
|
||||
}
|
||||
const renderedDrawings = { ...this._drawingManagerService.getDrawingData(unitId, unitId) };
|
||||
for (const drawingId of drawingIds) {
|
||||
for (const drawingId of synchronizedDrawingIds) {
|
||||
const current = drawingData[drawingId];
|
||||
if (current) {
|
||||
// Layout mutates render transforms. Never share the persisted drawing object or
|
||||
@@ -514,7 +511,7 @@ export class DocDrawingAddRemoveController extends Disposable {
|
||||
this._drawingManagerService.setDrawingOrder(unitId, unitId, getDocDrawingRenderOrder(drawingsOrder, drawings));
|
||||
}
|
||||
|
||||
const objects = drawingIds
|
||||
const objects = synchronizedDrawingIds
|
||||
.filter((drawingId) => drawingData[drawingId] != null)
|
||||
.map((drawingId) => ({ unitId, subUnitId: unitId, drawingId }));
|
||||
|
||||
@@ -522,6 +519,9 @@ export class DocDrawingAddRemoveController extends Disposable {
|
||||
return;
|
||||
}
|
||||
|
||||
if (hierarchyChanged) {
|
||||
this._drawingManagerService.addNotification(Object.values(renderedDrawings));
|
||||
}
|
||||
this._docDrawingService.updateNotification(objects);
|
||||
this._drawingManagerService.updateNotification(objects);
|
||||
}
|
||||
|
||||
+65
-4
@@ -36,6 +36,7 @@ import {
|
||||
AlignTypeV,
|
||||
BooleanNumber,
|
||||
Disposable,
|
||||
DrawingTypeEnum,
|
||||
fromEventSubject,
|
||||
ICommandService,
|
||||
Inject,
|
||||
@@ -47,6 +48,7 @@ import {
|
||||
PositionedObjectLayoutType,
|
||||
} from '@univerjs/core';
|
||||
import { DocSkeletonManagerService, RichTextEditingMutation } from '@univerjs/docs';
|
||||
import { collectDocDrawings } from '@univerjs/docs-drawing';
|
||||
import { IEditorService, SetDocZoomRatioOperation } from '@univerjs/docs-ui';
|
||||
import { IDrawingManagerService } from '@univerjs/drawing';
|
||||
import {
|
||||
@@ -731,14 +733,15 @@ export class DocDrawingTransformUpdateController extends Disposable implements I
|
||||
this._liquid.translatePage(page, pageLayoutType, pageMarginLeft, pageMarginTop);
|
||||
}
|
||||
|
||||
const affectedDrawingIds = this._includeGroupChildren(drawingIds);
|
||||
const updateDrawings = Object.values(updateDrawingMap)
|
||||
.filter((drawing) => drawingIds == null || drawingIds.has(drawing.drawingId));
|
||||
.filter((drawing) => affectedDrawingIds == null || affectedDrawingIds.has(drawing.drawingId));
|
||||
|
||||
for (const drawing of updateDrawings) {
|
||||
drawing.hidden = false;
|
||||
}
|
||||
|
||||
const staleNonMultiDrawings = this._getStaleNonMultiDrawings(unitId, updateDrawingMap, drawingIds);
|
||||
const staleNonMultiDrawings = this._getStaleNonMultiDrawings(unitId, updateDrawingMap, affectedDrawingIds);
|
||||
const nonMultiDrawings = updateDrawings
|
||||
.filter((drawing) => !drawing.isMultiTransform)
|
||||
.concat(staleNonMultiDrawings)
|
||||
@@ -754,10 +757,10 @@ export class DocDrawingTransformUpdateController extends Disposable implements I
|
||||
this._drawingManagerService.refreshTransform(nonMultiDrawings as unknown as IDrawingParam[]);
|
||||
}
|
||||
|
||||
const targetedMultiDrawing = drawingIds != null && [...drawingIds].some((drawingId) =>
|
||||
const targetedMultiDrawing = affectedDrawingIds != null && [...affectedDrawingIds].some((drawingId) =>
|
||||
updateDrawingMap[drawingId]?.isMultiTransform === BooleanNumber.TRUE ||
|
||||
this._drawingManagerService.getDrawingByParam({ unitId, subUnitId: unitId, drawingId })?.isMultiTransform === BooleanNumber.TRUE);
|
||||
if (drawingIds == null || targetedMultiDrawing) {
|
||||
if (affectedDrawingIds == null || targetedMultiDrawing) {
|
||||
// Multi-transform drawings are recreated as a group so repeated
|
||||
// header/footer occurrences stay consistent.
|
||||
const allMultiDrawings = Object.values(updateDrawingMap)
|
||||
@@ -766,6 +769,24 @@ export class DocDrawingTransformUpdateController extends Disposable implements I
|
||||
}
|
||||
}
|
||||
|
||||
private _includeGroupChildren(drawingIds?: ReadonlySet<string>): ReadonlySet<string> | undefined {
|
||||
if (drawingIds == null) {
|
||||
return;
|
||||
}
|
||||
const affected = new Set(drawingIds);
|
||||
const { drawings } = collectDocDrawings(this._context.unit.getSnapshot());
|
||||
let previousSize = -1;
|
||||
while (previousSize !== affected.size) {
|
||||
previousSize = affected.size;
|
||||
for (const drawing of Object.values(drawings)) {
|
||||
if (drawing.groupId && affected.has(drawing.groupId)) {
|
||||
affected.add(drawing.drawingId);
|
||||
}
|
||||
}
|
||||
}
|
||||
return affected;
|
||||
}
|
||||
|
||||
private _collectPublishedPageDrawingPositions(
|
||||
unitId: string,
|
||||
page: IDocumentSkeletonPage,
|
||||
@@ -1070,6 +1091,46 @@ export class DocDrawingTransformUpdateController extends Disposable implements I
|
||||
customBlockRenderViewport: drawing.customBlockRenderViewport,
|
||||
isMultiTransform,
|
||||
});
|
||||
if (runtimeDrawing.drawingType === DrawingTypeEnum.DRAWING_GROUP) {
|
||||
this._collectGroupChildren(drawingId, context, behindText, isMultiTransform, new Set([drawingId]));
|
||||
}
|
||||
}
|
||||
|
||||
private _collectGroupChildren(
|
||||
groupId: string,
|
||||
context: IDrawingPositionContext,
|
||||
behindText: boolean,
|
||||
isMultiTransform: BooleanNumber,
|
||||
visited: Set<string>
|
||||
): void {
|
||||
const { drawings } = collectDocDrawings(this._context.unit.getSnapshot());
|
||||
for (const child of Object.values(drawings)) {
|
||||
if (child.groupId !== groupId || visited.has(child.drawingId)) {
|
||||
continue;
|
||||
}
|
||||
visited.add(child.drawingId);
|
||||
const { size, positionH, positionV, angle, flipX, flipY } = child.docTransform;
|
||||
const transform = {
|
||||
left: positionH.posOffset ?? 0,
|
||||
top: positionV.posOffset ?? 0,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
angle,
|
||||
flipX,
|
||||
flipY,
|
||||
};
|
||||
mergePublishedDrawing(context.updateDrawingMap, {
|
||||
unitId: context.unitId,
|
||||
subUnitId: context.unitId,
|
||||
drawingId: child.drawingId,
|
||||
behindText,
|
||||
selectable: context.selectable,
|
||||
transform,
|
||||
transforms: [transform],
|
||||
isMultiTransform,
|
||||
});
|
||||
this._collectGroupChildren(child.drawingId, context, behindText, isMultiTransform, visited);
|
||||
}
|
||||
}
|
||||
|
||||
private _calculateTableCellDrawingPositions(
|
||||
|
||||
@@ -34,7 +34,7 @@ import { IDrawingManagerService } from '@univerjs/drawing';
|
||||
import { COMPONENT_MOBILE_IMAGE_POPUP_MENU, OpenImageCropOperation } from '@univerjs/drawing-ui';
|
||||
import { IRenderManagerService } from '@univerjs/engine-render';
|
||||
import { UnitAction } from '@univerjs/protocol';
|
||||
import { IMenuManagerService } from '@univerjs/ui';
|
||||
import { ContextMenuPosition, IContextMenuService, IMenuManagerService } from '@univerjs/ui';
|
||||
import { Subject } from 'rxjs';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
@@ -50,6 +50,7 @@ function createControllerHarness(drawingType = DrawingTypeEnum.DRAWING_IMAGE, mo
|
||||
const createControl$ = new Subject<void>();
|
||||
const clearControl$ = new Subject<boolean>();
|
||||
const changing$ = new Subject<void>();
|
||||
const changeEnd$ = new Subject<{ event: { button: number } }>();
|
||||
const focus$ = new Subject<never[]>();
|
||||
const remove$ = new Subject<IDrawingSearch[]>();
|
||||
const currentDocument$ = new Subject<never>();
|
||||
@@ -79,6 +80,7 @@ function createControllerHarness(drawingType = DrawingTypeEnum.DRAWING_IMAGE, mo
|
||||
createControl$,
|
||||
clearControl$,
|
||||
changing$,
|
||||
changeEnd$,
|
||||
getSelectedObjectMap: () => selectedObjects,
|
||||
clearSelectedObjects: vi.fn(),
|
||||
};
|
||||
@@ -88,6 +90,8 @@ function createControllerHarness(drawingType = DrawingTypeEnum.DRAWING_IMAGE, mo
|
||||
};
|
||||
|
||||
const executeCommand = vi.fn();
|
||||
const triggerContextMenu = vi.fn();
|
||||
injector.add([IContextMenuService, { useValue: { triggerContextMenu } as never }]);
|
||||
injector.add([ICommandService, {
|
||||
useValue: {
|
||||
executeCommand,
|
||||
@@ -153,6 +157,8 @@ function createControllerHarness(drawingType = DrawingTypeEnum.DRAWING_IMAGE, mo
|
||||
|
||||
return {
|
||||
clearControl$,
|
||||
changeEnd$,
|
||||
triggerContextMenu,
|
||||
createControl$,
|
||||
contextService,
|
||||
controller,
|
||||
@@ -167,6 +173,20 @@ function createControllerHarness(drawingType = DrawingTypeEnum.DRAWING_IMAGE, mo
|
||||
}
|
||||
|
||||
describe('DocDrawingPopupMenuController', () => {
|
||||
it('opens the shared drawing menu for a selected native group', () => {
|
||||
const harness = createControllerHarness(DrawingTypeEnum.DRAWING_GROUP);
|
||||
try {
|
||||
const event = { button: 2 };
|
||||
harness.changeEnd$.next({ event });
|
||||
expect(harness.triggerContextMenu).toHaveBeenCalledWith(event, ContextMenuPosition.DRAWING);
|
||||
harness.controller.dispose();
|
||||
harness.changeEnd$.next({ event });
|
||||
expect(harness.triggerContextMenu).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
harness.injector.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the mobile popup controller independent from the desktop controller', () => {
|
||||
expect(MobileDocDrawingPopupMenuController.prototype).not.toBeInstanceOf(DocDrawingPopupMenuController);
|
||||
});
|
||||
|
||||
@@ -45,7 +45,7 @@ import {
|
||||
OpenImageCropOperation,
|
||||
} from '@univerjs/drawing-ui';
|
||||
import { IRenderManagerService } from '@univerjs/engine-render';
|
||||
import { FloatingObjectToolbarPosition, IMenuManagerService, MenuItemType } from '@univerjs/ui';
|
||||
import { ContextMenuPosition, FloatingObjectToolbarPosition, IContextMenuService, IMenuManagerService, MenuItemType } from '@univerjs/ui';
|
||||
import { takeUntil } from 'rxjs';
|
||||
import { EditDocDrawingOperation } from '../commands/operations/edit-doc-drawing.operation';
|
||||
import { SidebarDocDrawingOperation } from '../commands/operations/open-drawing-panel.operation';
|
||||
@@ -76,7 +76,8 @@ export class DocDrawingPopupMenuController extends RxDisposable {
|
||||
@Inject(DocDrawingFloatingToolbarAdapterService) private readonly _floatingToolbarAdapterService: DocDrawingFloatingToolbarAdapterService,
|
||||
@ICommandService private readonly _commandService: ICommandService,
|
||||
@IMenuManagerService private readonly _menuManagerService: IMenuManagerService,
|
||||
@IPermissionService private readonly _permissionService: IPermissionService
|
||||
@IPermissionService private readonly _permissionService: IPermissionService,
|
||||
@IContextMenuService private readonly _contextMenuService: IContextMenuService
|
||||
) {
|
||||
super();
|
||||
|
||||
@@ -296,6 +297,20 @@ export class DocDrawingPopupMenuController extends RxDisposable {
|
||||
const transformer = scene.getTransformerByCreate();
|
||||
|
||||
const subscriptions = [
|
||||
transformer.changeEnd$.subscribe(({ event }) => {
|
||||
if (event.button !== 2) {
|
||||
return;
|
||||
}
|
||||
const drawings = [...transformer.getSelectedObjectMap().values()]
|
||||
.map((object) => this._drawingManagerService.getDrawingOKey(object.oKey));
|
||||
if (drawings.length === 0 || (drawings.length === 1 && drawings[0]?.drawingType !== DrawingTypeEnum.DRAWING_GROUP)) {
|
||||
return;
|
||||
}
|
||||
if (drawings.some((drawing) => !drawing || !this._canEditDrawing(drawing.unitId, drawing.drawingId))) {
|
||||
return;
|
||||
}
|
||||
this._contextMenuService.triggerContextMenu(event, ContextMenuPosition.DRAWING);
|
||||
}),
|
||||
transformer.createControl$.subscribe(() => {
|
||||
if (this._hasCropObject(scene)) {
|
||||
this._clearPopups(unitId, true);
|
||||
|
||||
@@ -94,16 +94,25 @@ export const RemoveDocDrawingCommand: ICommand = {
|
||||
|
||||
const drawings = source.drawings ?? {};
|
||||
const removeDrawingParamById = new Map(removeDrawings.map((drawing) => [drawing.drawingId, drawing]));
|
||||
const removeDrawingSnapshots = removeCustomBlocks
|
||||
.map((block) => drawings[block!.blockId] as IDocDrawing | undefined)
|
||||
const removeIds = new Set(removeCustomBlocks.map((block) => block!.blockId));
|
||||
let previousSize = -1;
|
||||
while (previousSize !== removeIds.size) {
|
||||
previousSize = removeIds.size;
|
||||
for (const drawing of Object.values(drawings)) {
|
||||
if (drawing.groupId && removeIds.has(drawing.groupId)) {
|
||||
removeIds.add(drawing.drawingId);
|
||||
}
|
||||
}
|
||||
}
|
||||
const removeDrawingSnapshots = [...removeIds]
|
||||
.map((id) => drawings[id] as IDocDrawing | undefined)
|
||||
.filter((drawing): drawing is IDocDrawing => drawing != null);
|
||||
|
||||
const resourceRedoMutations: IMutationInfo[] = [];
|
||||
const resourceUndoMutations: IMutationInfo[] = [];
|
||||
const resourceMutationGroups: Array<{ redoMutations: IMutationInfo[]; undoMutations: IMutationInfo[] }> = [];
|
||||
|
||||
for (const block of removeCustomBlocks) {
|
||||
const { blockId } = block!;
|
||||
for (const blockId of removeIds) {
|
||||
const drawing = drawings[blockId] as IDocDrawing | undefined;
|
||||
if (drawing == null) {
|
||||
continue;
|
||||
@@ -164,11 +173,8 @@ export const RemoveDocDrawingCommand: ICommand = {
|
||||
rawActions.push(jsonX.editOp(textX.serialize(), getRichTextEditPath(documentDataModel, segmentId))!);
|
||||
|
||||
const drawingOrder = source.drawingsOrder ?? [];
|
||||
const resourceBlocks = [...removeCustomBlocks].sort((left, right) =>
|
||||
drawingOrder.indexOf(right!.blockId) - drawingOrder.indexOf(left!.blockId)
|
||||
);
|
||||
for (const block of resourceBlocks) {
|
||||
const { blockId } = block!;
|
||||
const resourceIds = [...removeIds].sort((left, right) => drawingOrder.indexOf(right) - drawingOrder.indexOf(left));
|
||||
for (const blockId of resourceIds) {
|
||||
const drawingIndex = drawingOrder.indexOf(blockId);
|
||||
if (drawings[blockId]) {
|
||||
rawActions.push(jsonX.removeOp([...prefix, 'drawings', blockId], drawings[blockId])!);
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* 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 { IDocumentBody, IDocumentData } from '@univerjs/core';
|
||||
import { DocumentDataModel, DocumentFlavor, LocaleService, SpacingRule, Univer } from '@univerjs/core';
|
||||
import { Canvas, Documents, DocumentSkeleton, DocumentViewModel, DrawingGroupObject, Engine, Image, invalidateDocumentFontMetrics, MAIN_VIEW_PORT_KEY, Rect, Scene, setDocsTableRenderViewportProvider, Vector2, Viewport } from '@univerjs/engine-render';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { genEmptyTable, genTableSource } from '../../commands/commands/table/table';
|
||||
import { DocPageRenderComponent } from '../doc-page-render-component';
|
||||
|
||||
describe('document page picking', () => {
|
||||
let univer: Univer;
|
||||
let localeService: LocaleService;
|
||||
let viewport: Viewport;
|
||||
let measuringContext: CanvasRenderingContext2D;
|
||||
const originalResizeObserver = globalThis.ResizeObserver;
|
||||
let container: HTMLDivElement;
|
||||
let engine: Engine;
|
||||
let scene: Scene;
|
||||
let canvas: Canvas;
|
||||
|
||||
beforeEach(() => {
|
||||
univer = new Univer();
|
||||
localeService = univer.__getInjector().get(LocaleService);
|
||||
vi.stubGlobal('ResizeObserver', class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
});
|
||||
container = document.createElement('div');
|
||||
container.style.width = '820px';
|
||||
container.style.height = '520px';
|
||||
document.body.appendChild(container);
|
||||
|
||||
engine = new Engine('document-engine', { elementWidth: 800, elementHeight: 500, dpr: 1 });
|
||||
engine.mount(container, false);
|
||||
|
||||
scene = new Scene('document-scene', engine);
|
||||
scene.transformByState({
|
||||
width: 1200,
|
||||
height: 900,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
});
|
||||
viewport = new Viewport(MAIN_VIEW_PORT_KEY, scene, {
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 600,
|
||||
height: 400,
|
||||
active: true,
|
||||
allowCache: true,
|
||||
bufferEdgeX: 10,
|
||||
bufferEdgeY: 8,
|
||||
});
|
||||
|
||||
canvas = new Canvas({ width: 800, height: 500, pixelRatio: 1 });
|
||||
measuringContext ??= document.createElement('canvas').getContext('2d')!;
|
||||
vi.spyOn(measuringContext, 'measureText').mockImplementation((text) => ({
|
||||
width: text.length * 8,
|
||||
fontBoundingBoxAscent: 8,
|
||||
fontBoundingBoxDescent: 2,
|
||||
actualBoundingBoxAscent: 8,
|
||||
actualBoundingBoxDescent: 2,
|
||||
} as TextMetrics));
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(measuringContext);
|
||||
invalidateDocumentFontMetrics(() => true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
canvas.dispose();
|
||||
viewport.dispose();
|
||||
scene.dispose();
|
||||
engine.dispose();
|
||||
univer.dispose();
|
||||
vi.stubGlobal('ResizeObserver', originalResizeObserver);
|
||||
container.remove();
|
||||
document.body.innerHTML = '';
|
||||
vi.restoreAllMocks();
|
||||
invalidateDocumentFontMetrics(() => true);
|
||||
setDocsTableRenderViewportProvider(null);
|
||||
});
|
||||
|
||||
function createParagraphLayoutTestBed(content: string, overrides: Partial<Omit<IDocumentData, 'body'>> & { body?: Partial<IDocumentBody> } = {}) {
|
||||
const dataStream = `${content}\r\n`;
|
||||
const dataModel = new DocumentDataModel({
|
||||
id: 'page-picking',
|
||||
...overrides,
|
||||
body: {
|
||||
dataStream,
|
||||
paragraphs: [{ startIndex: content.length, paragraphId: 'paragraph' }],
|
||||
sectionBreaks: [{ startIndex: content.length + 1, sectionId: 'section' }],
|
||||
...overrides.body,
|
||||
},
|
||||
documentStyle: {
|
||||
pageSize: { width: 400, height: 600 },
|
||||
marginTop: 20,
|
||||
marginBottom: 20,
|
||||
marginLeft: 20,
|
||||
marginRight: 20,
|
||||
...overrides.documentStyle,
|
||||
},
|
||||
});
|
||||
return { dataModel, viewModel: new DocumentViewModel(dataModel) };
|
||||
}
|
||||
|
||||
it.each(['image', 'shape', 'group'] as const)('selects a rear %s through page whitespace while preserving text hits', (kind) => {
|
||||
const bed = createParagraphLayoutTestBed('Editable text', {
|
||||
documentStyle: { documentFlavor: DocumentFlavor.TRADITIONAL },
|
||||
});
|
||||
const skeleton = DocumentSkeleton.create(bed.viewModel, localeService);
|
||||
skeleton.calculate();
|
||||
const documents = new DocPageRenderComponent('rear-drawing-doc', skeleton, { pageMarginLeft: 0, pageMarginTop: 0 });
|
||||
documents.transformByState({ left: 40, top: 30, width: 400, height: 600 });
|
||||
const drawing = kind === 'shape' ? new Rect('rear-shape') : new Image('rear-image', {});
|
||||
drawing.transformByState({ width: 250, height: 250 });
|
||||
const rear = kind === 'group' ? new DrawingGroupObject('rear-group', drawing) : drawing;
|
||||
if (rear instanceof DrawingGroupObject) {
|
||||
rear.setBaseBound({ left: 0, top: 0, width: 250, height: 250 });
|
||||
}
|
||||
rear.transformByState({ left: 40, top: 30, width: 250, height: 250 });
|
||||
scene.addObject(documents, 2);
|
||||
scene.addObject(rear, 1);
|
||||
try {
|
||||
const page = skeleton.getSkeletonData()!.pages[0];
|
||||
const line = page.sections[0].columns[0].lines[0];
|
||||
const glyph = line.divides[0].glyphGroup[0];
|
||||
const text = Vector2.create(40 + page.marginLeft + glyph.width / 2, 30 + page.marginTop + line.top + line.lineHeight / 2);
|
||||
const blank = Vector2.create(200, 200);
|
||||
expect(scene.pick(text)).toBe(documents);
|
||||
expect(scene.pick(blank)).toBe(rear);
|
||||
// Blank space with no underlying object still places the document caret.
|
||||
expect(scene.pick(Vector2.create(350, 300))).toBe(documents);
|
||||
rear.hide();
|
||||
expect(scene.pick(blank)).toBe(documents);
|
||||
rear.show();
|
||||
rear.evented = false;
|
||||
expect(scene.pick(blank)).toBe(documents);
|
||||
rear.evented = true;
|
||||
const foreground = new Rect('front', { left: 190, top: 190, width: 30, height: 30 });
|
||||
scene.addObject(foreground, 3);
|
||||
expect(scene.pick(blank)).toBe(foreground);
|
||||
foreground.dispose();
|
||||
// Coordinate conversion must remain correct under scene zoom.
|
||||
scene.transformByState({ scaleX: 1.5, scaleY: 1.5 });
|
||||
expect(scene.pick(Vector2.create(300, 300))).toBe(rear);
|
||||
} finally {
|
||||
documents.dispose();
|
||||
rear.dispose();
|
||||
skeleton.dispose();
|
||||
bed.dataModel.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([0, 48])('keeps painted text above rear drawings with %s pt paragraph spacing', (spaceAbove) => {
|
||||
const content = 'Intro\rEditable text';
|
||||
const bed = createParagraphLayoutTestBed(content, {
|
||||
documentStyle: { documentFlavor: DocumentFlavor.TRADITIONAL },
|
||||
body: {
|
||||
paragraphs: [5, content.length].map((startIndex) => ({
|
||||
startIndex,
|
||||
paragraphId: `paragraph-${startIndex}`,
|
||||
paragraphStyle: { spaceAbove: { v: spaceAbove }, lineSpacing: 60, spacingRule: SpacingRule.EXACT },
|
||||
})),
|
||||
},
|
||||
});
|
||||
const skeleton = DocumentSkeleton.create(bed.viewModel, localeService);
|
||||
skeleton.calculate();
|
||||
const documents = new DocPageRenderComponent('spaced-text-doc', skeleton, { pageMarginLeft: 0, pageMarginTop: 0 });
|
||||
documents.transformByState({ left: 40, top: 30, width: 400, height: 600 });
|
||||
const rear = new Rect('spaced-text-rear', { left: 40, top: 30, width: 300, height: 350 });
|
||||
scene.addObject(documents, 2);
|
||||
scene.addObject(rear, 1);
|
||||
try {
|
||||
const line = skeleton.getSkeletonData()!.pages[0].sections[0].columns[0].lines[1];
|
||||
const glyph = line.divides[0].glyphGroup[0];
|
||||
expect(line.contentHeight).toBeGreaterThan(0);
|
||||
const font = documents.extensions.get('DefaultDocsFontAndBaseLineExtension')!;
|
||||
const draw = font.draw.bind(font);
|
||||
let paintedStart: Vector2 | undefined;
|
||||
vi.spyOn(font, 'draw').mockImplementation((...args) => {
|
||||
if (args[2] === glyph) {
|
||||
paintedStart = font.extensionOffset.spanStartPoint!.clone();
|
||||
}
|
||||
draw(...args);
|
||||
});
|
||||
documents.draw(canvas.getContext());
|
||||
expect(paintedStart).toBeDefined();
|
||||
const text = documents.transform.applyPoint(Vector2.create(paintedStart!.x + glyph.width / 2, paintedStart!.y + line.contentHeight / 2));
|
||||
expect(scene.pick(text)).toBe(documents);
|
||||
const padding = documents.transform.applyPoint(Vector2.create(paintedStart!.x + glyph.width / 2, paintedStart!.y - 4));
|
||||
expect(scene.pick(padding)).toBe(rear);
|
||||
scene.getTransformerByCreate().setSelectedControl(rear);
|
||||
expect(scene.pick(text)).toBe(documents);
|
||||
scene.transformByState({ scaleX: 1.5, scaleY: 1.5 });
|
||||
expect(scene.pick(Vector2.create(text.x * 1.5, text.y * 1.5))).toBe(documents);
|
||||
} finally {
|
||||
documents.dispose();
|
||||
rear.dispose();
|
||||
skeleton.dispose();
|
||||
bed.viewModel.dispose();
|
||||
bed.dataModel.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps table cell whitespace ahead of a rear drawing', () => {
|
||||
const tableBody = genEmptyTable(1, 1);
|
||||
const dataStream = `${tableBody.dataStream}\r\n`;
|
||||
const bed = createParagraphLayoutTestBed('', {
|
||||
documentStyle: { documentFlavor: DocumentFlavor.TRADITIONAL },
|
||||
body: {
|
||||
...tableBody,
|
||||
dataStream,
|
||||
paragraphs: [...tableBody.paragraphs, { startIndex: dataStream.length - 2, paragraphId: 'tail' }],
|
||||
sectionBreaks: [...tableBody.sectionBreaks, { startIndex: dataStream.length - 1, sectionId: 'tail-section' }],
|
||||
tables: [{ startIndex: 0, endIndex: tableBody.dataStream.length, tableId: 'table-1' }],
|
||||
},
|
||||
tableSource: { 'table-1': { ...genTableSource(1, 1, 120), tableId: 'table-1' } },
|
||||
});
|
||||
const skeleton = DocumentSkeleton.create(bed.viewModel, localeService);
|
||||
skeleton.calculate();
|
||||
const documents = new DocPageRenderComponent('rear-table-doc', skeleton, { pageMarginLeft: 0, pageMarginTop: 0 });
|
||||
documents.transformByState({ width: 400, height: 600 });
|
||||
const rear = new Image('rear-table-image', { left: 0, top: 0, width: 300, height: 250 });
|
||||
scene.addObject(documents, 2);
|
||||
scene.addObject(rear, 1);
|
||||
try {
|
||||
const page = skeleton.getSkeletonData()!.pages[0];
|
||||
const table = page.skeTables.get('table-1')!;
|
||||
expect(table).toBeDefined();
|
||||
expect(scene.pick(Vector2.create(page.marginLeft + table.left + table.width / 2, page.marginTop + table.top + table.height / 2))).toBe(documents);
|
||||
expect(scene.pick(Vector2.create(250, 200))).toBe(rear);
|
||||
} finally {
|
||||
documents.dispose();
|
||||
rear.dispose();
|
||||
skeleton.dispose();
|
||||
bed.viewModel.dispose();
|
||||
bed.dataModel.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it('leaves the shared text renderer opaque over rear objects', () => {
|
||||
const bed = createParagraphLayoutTestBed('Text');
|
||||
const skeleton = DocumentSkeleton.create(bed.viewModel, localeService);
|
||||
skeleton.calculate();
|
||||
const documents = new Documents('shared-text', skeleton);
|
||||
documents.transformByState({ width: 400, height: 600 });
|
||||
const rear = new Rect('rear', { width: 300, height: 250 });
|
||||
scene.addObject(documents, 2);
|
||||
scene.addObject(rear, 1);
|
||||
try {
|
||||
expect(scene.pick(Vector2.create(200, 200))).toBe(documents);
|
||||
} finally {
|
||||
documents.dispose();
|
||||
rear.dispose();
|
||||
skeleton.dispose();
|
||||
bed.viewModel.dispose();
|
||||
bed.dataModel.dispose();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { Vector2 } from '@univerjs/engine-render';
|
||||
import { DocBackground, Documents, documentSkeletonTableIterator } from '@univerjs/engine-render';
|
||||
import { NodePositionConvertToCursor } from '../services/selection/convert-text-range';
|
||||
|
||||
/** Full document pages let whitespace select drawings behind the text. */
|
||||
export class DocPageRenderComponent extends Documents {
|
||||
override isHit(coord: Vector2): boolean {
|
||||
if (!super.isHit(coord)) {
|
||||
return false;
|
||||
}
|
||||
const skeleton = this.getSkeleton();
|
||||
const layer = this.layer;
|
||||
const hasDrawingBehind = layer != null && this.getScene()?.getLayers().some((candidate) =>
|
||||
candidate.zIndex < layer.zIndex && candidate.getObjects().some((object) =>
|
||||
!(object instanceof Documents) && !(object instanceof DocBackground) && !object.isInGroup &&
|
||||
object.visible && object.evented && object.isHit(coord)));
|
||||
if (!hasDrawingBehind || !skeleton) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const local = this.getInverseCoord(coord);
|
||||
const hit = skeleton.findNodeByCoord(local, this.pageLayoutType, this.pageMarginLeft, this.pageMarginTop);
|
||||
if (hit?.node.content?.trim()) {
|
||||
const position = skeleton.findPositionByGlyph(hit.node, hit.segmentPage);
|
||||
if (position) {
|
||||
// Reuse the editor's glyph selection geometry, excluding line padding.
|
||||
const converter = new NodePositionConvertToCursor(this.getOffsetConfig(), skeleton);
|
||||
const { contentBoxPointGroup } = converter.getRangePointData(
|
||||
{ ...position, isBack: true },
|
||||
{ ...position, isBack: false }
|
||||
);
|
||||
if (contentBoxPointGroup.some((points) => local.x >= points[0].x && local.x <= points[2].x &&
|
||||
local.y >= points[0].y && local.y <= points[2].y)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const data = skeleton.getSkeletonData();
|
||||
if (!data) {
|
||||
return false;
|
||||
}
|
||||
const { docsLeft, docsTop } = this.getOffsetConfig();
|
||||
// Table cells and their editor controls keep priority over rear drawings.
|
||||
const controlPadding = 24;
|
||||
return documentSkeletonTableIterator(data.pages, {
|
||||
docsLeft,
|
||||
docsTop,
|
||||
includeCells: false,
|
||||
pageMarginTop: this.pageMarginTop,
|
||||
skeHeaders: data.skeHeaders,
|
||||
skeFooters: data.skeFooters,
|
||||
unitId: skeleton.getViewModel().getDataModel().getUnitId(),
|
||||
}).some(({ tableRect }) => local.x + docsLeft >= tableRect.left - controlPadding &&
|
||||
local.x + docsLeft <= tableRect.right + controlPadding &&
|
||||
local.y + docsTop >= tableRect.top - controlPadding &&
|
||||
local.y + docsTop <= tableRect.bottom + controlPadding);
|
||||
}
|
||||
}
|
||||
@@ -76,6 +76,7 @@ import {
|
||||
DOCS_VIEW_KEY,
|
||||
VIEWPORT_KEY,
|
||||
} from '../../basics/docs-view-key';
|
||||
import { DocPageRenderComponent } from '../../components/doc-page-render-component';
|
||||
import { DocLayoutCoordinatorService } from '../../services/doc-layout-coordinator.service';
|
||||
import { DocLayoutInteractionService } from '../../services/doc-layout-interaction.service';
|
||||
import { DocPageLayoutService } from '../../services/doc-page-layout.service';
|
||||
@@ -1680,7 +1681,8 @@ export class DocRenderController extends RxDisposable implements IRenderModule {
|
||||
...this._getEditorBackgroundConfig(),
|
||||
};
|
||||
|
||||
const documents = new Documents(DOCS_VIEW_KEY.MAIN, undefined, config);
|
||||
const DocumentComponent = this._isEditorRenderUnit(documentModel.getUnitId()) ? Documents : DocPageRenderComponent;
|
||||
const documents = new DocumentComponent(DOCS_VIEW_KEY.MAIN, undefined, config);
|
||||
documents.zIndex = DOCS_COMPONENT_DEFAULT_Z_INDEX;
|
||||
const docBackground = new DocBackground(DOCS_VIEW_KEY.BACKGROUND, undefined, config);
|
||||
docBackground.zIndex = DOCS_COMPONENT_DEFAULT_Z_INDEX;
|
||||
|
||||
@@ -55,7 +55,7 @@ vi.mock('@univerjs/drawing', async (importOriginal) => {
|
||||
|
||||
return {
|
||||
...actual,
|
||||
getDrawingShapeKeyByDrawingSearch: vi.fn(({ drawingId }) => `group-${drawingId}`),
|
||||
getDrawingShapeKeyByDrawingSearch: vi.fn(({ drawingId }, index?: number) => `group-${drawingId}${index == null ? '' : `-${index}`}`),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -66,6 +66,48 @@ vi.mock('@univerjs/engine-render', () => ({
|
||||
}));
|
||||
|
||||
describe('drawing controller utils', () => {
|
||||
it('keeps repeated document groups separate and preserves their page layer and stacking order', () => {
|
||||
const scene = {
|
||||
objects: new Map<string, InstanceType<typeof MockGroup>>(),
|
||||
getObjectIncludeInGroup(key: string) {
|
||||
return this.objects.get(key);
|
||||
},
|
||||
addObject: vi.fn((group: InstanceType<typeof MockGroup>, _layer: number) => {
|
||||
scene.objects.set(group.oKey, group);
|
||||
return { attachTransformerTo: vi.fn() };
|
||||
}),
|
||||
};
|
||||
const drawingManagerService = {
|
||||
getDrawingByParam: vi.fn(() => ({
|
||||
drawingType: DrawingTypeEnum.DRAWING_GROUP,
|
||||
transforms: [
|
||||
{ left: 20, top: 30, width: 100, height: 40, flipX: true },
|
||||
{ left: 20, top: 830, width: 100, height: 40, flipY: true },
|
||||
],
|
||||
})),
|
||||
getDrawingOrder: vi.fn(() => ['background', 'header']),
|
||||
getDrawingsByGroup: vi.fn(() => []),
|
||||
};
|
||||
for (const index of [0, 1]) {
|
||||
insertGroupObject(
|
||||
{ unitId: 'doc', subUnitId: 'doc', drawingId: 'header' },
|
||||
{ oKey: `child-${index}`, layer: { zIndex: 7 } } as never,
|
||||
scene as never,
|
||||
drawingManagerService as never,
|
||||
index
|
||||
);
|
||||
}
|
||||
const first = scene.objects.get('group-header-0')!;
|
||||
const second = scene.objects.get('group-header-1')!;
|
||||
expect(first).not.toBe(second);
|
||||
expect(first.getObjects()).toHaveLength(1);
|
||||
expect(second.getObjects()).toHaveLength(1);
|
||||
expect(first).toMatchObject({ zIndex: 1 });
|
||||
expect(scene.addObject).toHaveBeenCalledWith(second, 7);
|
||||
expect(first.transformByState).toHaveBeenCalledWith(expect.objectContaining({ top: 30, flipX: true }));
|
||||
expect(second.transformByState).toHaveBeenCalledWith(expect.objectContaining({ top: 830, flipY: true }));
|
||||
});
|
||||
|
||||
it('creates a new drawing group, attaches it to the scene, and avoids duplicate children', () => {
|
||||
const object = { oKey: 'child-1' };
|
||||
const attachTransformerTo = vi.fn();
|
||||
@@ -93,17 +135,18 @@ describe('drawing controller utils', () => {
|
||||
transform: { left: 10, top: 20, width: 30, height: 40, angle: 15 },
|
||||
groupBaseBound: { left: 1, top: 2, width: 3, height: 4 },
|
||||
})),
|
||||
getDrawingOrder: vi.fn(() => ['background', 'group-1']),
|
||||
getDrawingsByGroup: vi.fn(() => []),
|
||||
};
|
||||
|
||||
insertGroupObject({ drawingId: 'group-1' } as never, object as never, scene as never, drawingManagerService as never);
|
||||
|
||||
const group = scene.getObject('group-group-1') as InstanceType<typeof MockGroup>;
|
||||
expect(getDrawingShapeKeyByDrawingSearch).toHaveBeenCalledWith({ drawingId: 'group-1' });
|
||||
expect(getDrawingShapeKeyByDrawingSearch).toHaveBeenCalledWith({ drawingId: 'group-1' }, undefined);
|
||||
expect(group).toBeInstanceOf(DrawingGroupObject as unknown as typeof MockGroup);
|
||||
expect(group.getObjects()).toEqual([object]);
|
||||
expect(group.setBaseBound).toHaveBeenCalledWith({ left: 1, top: 2, width: 3, height: 4 });
|
||||
expect(group.transformByState).toHaveBeenCalledWith({ left: 10, top: 20, width: 30, height: 40, angle: 15 });
|
||||
expect(group.transformByState).toHaveBeenCalledWith({ left: 10, top: 20, width: 30, height: 40, angle: 15, flipX: undefined, flipY: undefined });
|
||||
expect(attachTransformerTo).toHaveBeenCalledWith(group);
|
||||
|
||||
insertGroupObject({ drawingId: 'group-1' } as never, object as never, scene as never, drawingManagerService as never);
|
||||
@@ -140,6 +183,7 @@ describe('drawing controller utils', () => {
|
||||
groupId: param.drawingId === 'child-group' ? 'parent-group' : undefined,
|
||||
transform: { left: 0, top: 0, width: 10, height: 10 },
|
||||
})),
|
||||
getDrawingOrder: vi.fn(() => ['background', 'group-1']),
|
||||
getDrawingsByGroup: vi.fn(() => []),
|
||||
};
|
||||
|
||||
@@ -176,6 +220,7 @@ describe('drawing controller utils', () => {
|
||||
drawingType: DrawingTypeEnum.DRAWING_GROUP,
|
||||
transform: { left: 10, top: 20, width: 30, height: 40, angle: 30 },
|
||||
})),
|
||||
getDrawingOrder: vi.fn(() => ['background', 'group-1']),
|
||||
getDrawingsByGroup: vi.fn(() => [{
|
||||
unitId: 'unit-1',
|
||||
subUnitId: 'sheet-1',
|
||||
@@ -190,7 +235,7 @@ describe('drawing controller utils', () => {
|
||||
|
||||
const group = scene.getObject('group-group-1') as InstanceType<typeof MockGroup>;
|
||||
expect(group.transformerConfig?.rotateEnabled).toBe(false);
|
||||
expect(group.transformByState).toHaveBeenCalledWith({ left: 10, top: 20, width: 30, height: 40, angle: 30 });
|
||||
expect(group.transformByState).toHaveBeenCalledWith({ left: 10, top: 20, width: 30, height: 40, angle: 30, flipX: undefined, flipY: undefined });
|
||||
});
|
||||
|
||||
it('uses explicit children when synchronizing group rotation state', () => {
|
||||
@@ -199,6 +244,7 @@ describe('drawing controller utils', () => {
|
||||
getObjectIncludeInGroup: vi.fn(() => null),
|
||||
};
|
||||
const drawingManagerService = {
|
||||
getDrawingOrder: vi.fn(() => ['background', 'group-1']),
|
||||
getDrawingsByGroup: vi.fn(() => []),
|
||||
};
|
||||
|
||||
@@ -226,6 +272,7 @@ describe('drawing controller utils', () => {
|
||||
};
|
||||
const drawingManagerService = {
|
||||
getDrawingByParam: vi.fn(() => ({ transform: { left: 0, top: 0, width: 10, height: 10 } })),
|
||||
getDrawingOrder: vi.fn(() => ['background', 'group-1']),
|
||||
getDrawingsByGroup: vi.fn(() => []),
|
||||
};
|
||||
|
||||
@@ -235,6 +282,7 @@ describe('drawing controller utils', () => {
|
||||
|
||||
insertGroupObject({ drawingId: 'missing-group' } as never, { oKey: 'child-3' } as never, scene as never, {
|
||||
getDrawingByParam: vi.fn(() => null),
|
||||
getDrawingOrder: vi.fn(() => ['background', 'group-1']),
|
||||
getDrawingsByGroup: vi.fn(() => []),
|
||||
} as never);
|
||||
|
||||
|
||||
@@ -73,13 +73,13 @@ export function syncGroupRotateEnabled(
|
||||
};
|
||||
}
|
||||
|
||||
export function insertGroupObject(objectParam: IDrawingSearch, object: BaseObject, scene: Scene, drawingManagerService: IDrawingManagerService) {
|
||||
export function insertGroupObject(objectParam: IDrawingSearch, object: BaseObject, scene: Scene, drawingManagerService: IDrawingManagerService, transformIndex?: number) {
|
||||
const groupParam = drawingManagerService.getDrawingByParam(objectParam);
|
||||
if (groupParam == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const groupKey = getDrawingShapeKeyByDrawingSearch(objectParam);
|
||||
const groupKey = getDrawingShapeKeyByDrawingSearch(objectParam, transformIndex);
|
||||
const groupObject = scene.getObjectIncludeInGroup(groupKey);
|
||||
|
||||
if (groupObject && !(groupObject instanceof Group)) {
|
||||
@@ -99,13 +99,16 @@ export function insertGroupObject(objectParam: IDrawingSearch, object: BaseObjec
|
||||
}
|
||||
|
||||
const group = new DrawingGroupObject(groupKey);
|
||||
const order = drawingManagerService.getDrawingOrder(objectParam.unitId, objectParam.subUnitId);
|
||||
group.zIndex = Math.max(0, order.indexOf(objectParam.drawingId));
|
||||
|
||||
scene.addObject(group, DRAWING_OBJECT_LAYER_INDEX).attachTransformerTo(group);
|
||||
scene.addObject(group, object.layer?.zIndex ?? DRAWING_OBJECT_LAYER_INDEX).attachTransformerTo(group);
|
||||
|
||||
group.addObject(object);
|
||||
syncGroupRotateEnabled(group, groupParam, scene, drawingManagerService);
|
||||
|
||||
const { transform, groupBaseBound } = groupParam;
|
||||
const { groupBaseBound } = groupParam;
|
||||
const transform = transformIndex == null ? groupParam.transform : groupParam.transforms?.[transformIndex];
|
||||
|
||||
if (groupBaseBound) {
|
||||
group.setBaseBound(groupBaseBound);
|
||||
@@ -117,7 +120,8 @@ export function insertGroupObject(objectParam: IDrawingSearch, object: BaseObjec
|
||||
{ drawingId: groupParam.groupId, unitId: objectParam.unitId, subUnitId: objectParam.subUnitId },
|
||||
group,
|
||||
scene,
|
||||
drawingManagerService
|
||||
drawingManagerService,
|
||||
transformIndex
|
||||
);
|
||||
}
|
||||
|
||||
@@ -128,6 +132,8 @@ export function insertGroupObject(objectParam: IDrawingSearch, object: BaseObjec
|
||||
angle: transform.angle,
|
||||
width: transform.width,
|
||||
height: transform.height,
|
||||
flipX: transform.flipX,
|
||||
flipY: transform.flipY,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -195,7 +195,7 @@ export class DrawingRenderService {
|
||||
scene.attachTransformerTo(image);
|
||||
}
|
||||
|
||||
groupId && insertGroupObject({ drawingId: groupId, unitId, subUnitId }, image, scene, this._drawingManagerService);
|
||||
groupId && insertGroupObject({ drawingId: groupId, unitId, subUnitId }, image, scene, this._drawingManagerService, isMultiTransform ? index : undefined);
|
||||
|
||||
if (prstGeom != null) {
|
||||
image.setPrstGeom(prstGeom);
|
||||
|
||||
+59
-2
@@ -16,9 +16,11 @@
|
||||
|
||||
import type { ISheetDrawing } from '@univerjs/sheets-drawing';
|
||||
import { Direction, DrawingTypeEnum, ImageSourceType } from '@univerjs/core';
|
||||
import { IRenderManagerService } from '@univerjs/engine-render';
|
||||
import { getDrawingShapeKeyByDrawingSearch, IDrawingManagerService } from '@univerjs/drawing';
|
||||
import { insertGroupObject } from '@univerjs/drawing-ui';
|
||||
import { DrawingGroupObject, Engine, Image, IRenderManagerService, MAIN_VIEW_PORT_KEY, Rect, Scene, Vector2, Viewport } from '@univerjs/engine-render';
|
||||
import { InsertSheetDrawingCommand, ISheetDrawingService } from '@univerjs/sheets-drawing';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createSheetsDrawingUiTestBed } from '../../../__tests__/create-sheets-drawing-ui-test-bed';
|
||||
import { FlipSheetDrawingCommand } from '../flip-drawings.command';
|
||||
import { GroupSheetDrawingCommand } from '../group-sheet-drawing.command';
|
||||
@@ -91,6 +93,61 @@ class TestRenderManagerService {
|
||||
}
|
||||
|
||||
describe('sheet drawing UI commands', () => {
|
||||
it.each([[false, false], [true, false], [false, true], [true, true]])('restores sheet group picking and stacking with flips %s/%s', async (flipX, flipY) => {
|
||||
const contextSpy = vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation(function (this: HTMLCanvasElement) {
|
||||
return { canvas: this, setTransform: vi.fn(), clearRect: vi.fn() } as unknown as CanvasRenderingContext2D;
|
||||
});
|
||||
const testBed = createSheetsDrawingUiTestBed();
|
||||
const engine = new Engine('group-engine', { elementWidth: 800, elementHeight: 500, dpr: 1 });
|
||||
const scene = new Scene('group-scene', engine);
|
||||
const viewport = new Viewport(MAIN_VIEW_PORT_KEY, scene, { left: 0, top: 0, width: 800, height: 500, active: true });
|
||||
try {
|
||||
testBed.commandService.registerCommand(GroupSheetDrawingCommand);
|
||||
const children = [createSheetDrawing('child-1', 0, 0), createSheetDrawing('child-2', 100, 80)];
|
||||
await insertDrawings(testBed, [createSheetDrawing('background', 0, 0), ...children]);
|
||||
const parent = {
|
||||
unitId: testBed.unitId,
|
||||
subUnitId: testBed.subUnitId,
|
||||
drawingId: 'group-1',
|
||||
drawingType: DrawingTypeEnum.DRAWING_GROUP,
|
||||
transform: { left: 100, top: 100, width: 200, height: 160, flipX, flipY },
|
||||
groupBaseBound: { left: 0, top: 0, width: 200, height: 160 },
|
||||
};
|
||||
expect(await testBed.commandService.executeCommand(GroupSheetDrawingCommand.id, [{
|
||||
parent,
|
||||
children: children.map((child) => ({ ...child, groupId: parent.drawingId })),
|
||||
}])).toBe(true);
|
||||
const manager = testBed.get(IDrawingManagerService);
|
||||
for (const child of children) {
|
||||
const object = new Image(getDrawingShapeKeyByDrawingSearch(child), { ...child.transform });
|
||||
scene.addObject(object, 7);
|
||||
insertGroupObject(parent, object, scene, manager);
|
||||
}
|
||||
const group = scene.getObjectIncludeInGroup(getDrawingShapeKeyByDrawingSearch(parent)) as DrawingGroupObject;
|
||||
expect(group).toBeInstanceOf(DrawingGroupObject);
|
||||
expect(group.getObjects()).toHaveLength(2);
|
||||
expect(group.layer?.zIndex).toBe(7);
|
||||
expect(group.zIndex).toBe(manager.getDrawingOrder(parent.unitId, parent.subUnitId).indexOf(parent.drawingId));
|
||||
const point = new Vector2(flipX ? 275 : 125, flipY ? 240 : 120);
|
||||
expect(scene.pick(point)).toBe(group);
|
||||
const overlay = new Rect('overlay', { left: 100, top: 100, width: 200, height: 160, fill: '#993333' });
|
||||
overlay.zIndex = group.zIndex + 1;
|
||||
scene.addObject(overlay, 7);
|
||||
expect(scene.pick(point)).toBe(overlay);
|
||||
overlay.zIndex = group.zIndex - 1;
|
||||
expect(scene.pick(point)).toBe(group);
|
||||
if (flipX !== flipY) {
|
||||
expect(group.isHit(new Vector2(125, 120))).toBe(false);
|
||||
}
|
||||
} finally {
|
||||
viewport.dispose();
|
||||
scene.dispose();
|
||||
engine.dispose();
|
||||
testBed.univer.dispose();
|
||||
contextSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// each test disposes its own univer instance
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user