mirror of
https://github.com/dream-num/univer.git
synced 2026-10-02 04:24:34 +08:00
fix(embed): stabilize runtime UI ownership (#7532)
This commit is contained in:
+38
-2
@@ -28,6 +28,8 @@ function createController(options: {
|
||||
editArea?: DocumentEditArea;
|
||||
drawings?: Record<string, unknown>;
|
||||
isFocusing?: boolean;
|
||||
openFile?: () => Promise<File[]>;
|
||||
saveImage?: (file: File) => Promise<unknown>;
|
||||
} = {}) {
|
||||
const featurePluginOrderUpdate$ = new Subject<any>();
|
||||
const featurePluginUpdate$ = new Subject<any[]>();
|
||||
@@ -117,6 +119,7 @@ function createController(options: {
|
||||
}),
|
||||
};
|
||||
const docSelectionManagerService = {
|
||||
getActiveTextRange: vi.fn(() => null),
|
||||
refreshSelection: vi.fn(),
|
||||
replaceDocRanges: vi.fn(),
|
||||
};
|
||||
@@ -143,18 +146,26 @@ function createController(options: {
|
||||
return isFocusing;
|
||||
},
|
||||
getActiveTextRange: vi.fn(() => null),
|
||||
getAllTextRanges: vi.fn(() => []),
|
||||
getSegment: vi.fn(() => ''),
|
||||
onBlur$,
|
||||
onFocus$,
|
||||
setSegment: vi.fn(),
|
||||
};
|
||||
const imageIoService = {
|
||||
addImageSourceCache: vi.fn(),
|
||||
saveImage: vi.fn(options.saveImage),
|
||||
};
|
||||
const fileOpenerService = {
|
||||
openFile: vi.fn(options.openFile ?? (async () => [])),
|
||||
};
|
||||
|
||||
const controller = new DocDrawingUpdateRenderController(
|
||||
context as never,
|
||||
commandService as never,
|
||||
docSelectionManagerService as never,
|
||||
renderManagerSrv as never,
|
||||
{} as never,
|
||||
imageIoService as never,
|
||||
docDrawingService as never,
|
||||
drawingManagerService as never,
|
||||
contextService as never,
|
||||
@@ -162,7 +173,7 @@ function createController(options: {
|
||||
{ t: vi.fn((key: string) => key) } as never,
|
||||
docSelectionRenderService as never,
|
||||
{ refreshDrawings$ } as never,
|
||||
{ openFile: vi.fn() } as never
|
||||
fileOpenerService as never
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -358,6 +369,31 @@ describe('DocDrawingUpdateRenderController', () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('cancels an image insertion when the render controller is disposed while saving', async () => {
|
||||
let finishSaving: (value: unknown) => void = () => {};
|
||||
const saveImage = vi.fn(() => new Promise<unknown>((resolve) => {
|
||||
finishSaving = resolve;
|
||||
}));
|
||||
const { commandService, controller } = createController({
|
||||
openFile: async () => [{} as File],
|
||||
saveImage,
|
||||
});
|
||||
|
||||
const insertion = controller.insertDocImage();
|
||||
await vi.waitFor(() => expect(saveImage).toHaveBeenCalledTimes(1));
|
||||
|
||||
controller.dispose();
|
||||
finishSaving({
|
||||
imageId: 'image-1',
|
||||
imageSourceType: 'URL',
|
||||
source: 'image.png',
|
||||
base64Cache: 'data:image/png;base64,',
|
||||
});
|
||||
|
||||
await expect(insertion).resolves.toBe(false);
|
||||
expect(commandService.executeCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('toggles drawing editability between body and header/footer edit areas', () => {
|
||||
const bodyDrawing = {
|
||||
drawingId: 'body-drawing',
|
||||
|
||||
+29
-11
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { DocumentDataModel, ICommandInfo, IDocDrawingPosition, IDrawingParam, IImageIoServiceParam, Nullable } from '@univerjs/core';
|
||||
import type { DocumentDataModel, ICommandInfo, IDocDrawingPosition, IDrawingParam, IImageIoServiceParam, ITextRangeParam, Nullable } from '@univerjs/core';
|
||||
import type { IRichTextEditingMutationParams } from '@univerjs/docs';
|
||||
import type { IDocDrawing, IDrawingDocTransform, IInsertDocDrawingCommandParams, ISetDocDrawingArrangeCommandParams, IUpdateDrawingDocTransformCommandParams } from '@univerjs/docs-drawing';
|
||||
import type { IImageData } from '@univerjs/drawing';
|
||||
@@ -94,11 +94,16 @@ export class DocDrawingUpdateRenderController extends Disposable implements IRen
|
||||
|
||||
async insertDocImage(): Promise<boolean> {
|
||||
const insertPosition = this._getCurrentImageInsertPosition();
|
||||
const textRange = this._getCurrentImageInsertTextRange();
|
||||
const files = await this._fileOpenerService.openFile({
|
||||
multiple: true,
|
||||
accept: DRAWING_IMAGE_ALLOW_IMAGE_LIST.map((image) => `.${image.replace('image/', '')}`).join(','),
|
||||
});
|
||||
|
||||
if (this._disposed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const fileLength = files.length;
|
||||
if (fileLength > DRAWING_IMAGE_COUNT_LIMIT) {
|
||||
this._messageService.show({
|
||||
@@ -110,12 +115,15 @@ export class DocDrawingUpdateRenderController extends Disposable implements IRen
|
||||
return false;
|
||||
}
|
||||
|
||||
await this._insertFloatImages(files, insertPosition);
|
||||
return true;
|
||||
return await this._insertFloatImages(files, insertPosition, textRange);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line max-lines-per-function
|
||||
private async _insertFloatImages(files: File[], insertPosition: Nullable<IImageInsertPosition>) {
|
||||
private async _insertFloatImages(
|
||||
files: File[],
|
||||
insertPosition: Nullable<IImageInsertPosition>,
|
||||
textRange: Nullable<ITextRangeParam>
|
||||
) {
|
||||
let imageParams: Nullable<IImageIoServiceParam>[] = [];
|
||||
|
||||
try {
|
||||
@@ -144,8 +152,8 @@ export class DocDrawingUpdateRenderController extends Disposable implements IRen
|
||||
});
|
||||
}
|
||||
|
||||
if (imageParams.length === 0) {
|
||||
return;
|
||||
if (this._disposed || imageParams.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { unitId } = this._context;
|
||||
@@ -158,6 +166,10 @@ export class DocDrawingUpdateRenderController extends Disposable implements IRen
|
||||
const { imageId, imageSourceType, source, base64Cache } = imageParam;
|
||||
const { width, height, image } = await getImageSize(base64Cache || '');
|
||||
|
||||
if (this._disposed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this._imageIoService.addImageSourceCache(imageId, imageSourceType, image);
|
||||
|
||||
let scale = 1;
|
||||
@@ -171,7 +183,7 @@ export class DocDrawingUpdateRenderController extends Disposable implements IRen
|
||||
const docTransform = this._getImagePosition(width * scale, height * scale, imagePosition);
|
||||
|
||||
if (docTransform == null) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
const transform = docDrawingPositionToTransform(docTransform);
|
||||
@@ -209,9 +221,10 @@ export class DocDrawingUpdateRenderController extends Disposable implements IRen
|
||||
docDrawingParams.push(docDrawingParam);
|
||||
}
|
||||
|
||||
this._commandService.executeCommand<IInsertDocDrawingCommandParams>(InsertDocDrawingCommand.id, {
|
||||
return await this._commandService.executeCommand<IInsertDocDrawingCommandParams>(InsertDocDrawingCommand.id, {
|
||||
unitId,
|
||||
drawings: docDrawingParams,
|
||||
textRange: textRange ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -255,6 +268,11 @@ export class DocDrawingUpdateRenderController extends Disposable implements IRen
|
||||
};
|
||||
}
|
||||
|
||||
private _getCurrentImageInsertTextRange(): Nullable<ITextRangeParam> {
|
||||
return this._docSelectionRenderService.getAllTextRanges().find((range) => range.isActive)
|
||||
?? this._docSelectionManagerService.getActiveTextRange();
|
||||
}
|
||||
|
||||
private _updateOrderListener() {
|
||||
this.disposeWithMe(
|
||||
this._drawingManagerService.featurePluginOrderUpdate$.subscribe((params) => {
|
||||
@@ -373,7 +391,7 @@ export class DocDrawingUpdateRenderController extends Disposable implements IRen
|
||||
private _transformDrawingListener() {
|
||||
const res = this._getCurrentSceneAndTransformer();
|
||||
if (res && res.transformer) {
|
||||
this.disposeWithMe(res.transformer.changeEnd$.pipe(debounceTime(30)).subscribe((params) => {
|
||||
this.disposeWithMe(res.transformer.changeEnd$.pipe(debounceTime(30)).subscribe(() => {
|
||||
this._docSelectionManagerService.refreshSelection();
|
||||
}));
|
||||
} else {
|
||||
@@ -474,7 +492,7 @@ export class DocDrawingUpdateRenderController extends Disposable implements IRen
|
||||
scene.detachTransformerFrom(shape);
|
||||
try {
|
||||
(shape as Image).setOpacity(isDocInputFocusing ? 0.5 : 1);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
}
|
||||
if (!isDocInputFocusing) {
|
||||
continue;
|
||||
@@ -489,7 +507,7 @@ export class DocDrawingUpdateRenderController extends Disposable implements IRen
|
||||
|
||||
try {
|
||||
(shape as Image).setOpacity(1);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
isInternalEditorID,
|
||||
IUniverInstanceService,
|
||||
RxDisposable,
|
||||
toDisposable,
|
||||
UniverInstanceType,
|
||||
} from '@univerjs/core';
|
||||
import { IDocDrawingAdapterService, RemoveDocDrawingCommand } from '@univerjs/docs-drawing';
|
||||
@@ -45,6 +46,8 @@ import { DocDrawingFloatingToolbarAdapterService } from '../services/doc-drawing
|
||||
|
||||
export class DocDrawingPopupMenuController extends RxDisposable {
|
||||
private _initImagePopupMenu = new Set<string>();
|
||||
private _embeddedRenderUnits = new Set<string>();
|
||||
private _popupMenuListeners = new Map<string, IDisposable>();
|
||||
private _disposePopups: IDisposable[] = [];
|
||||
private _isDrawingPanelOpen = false;
|
||||
|
||||
@@ -96,12 +99,34 @@ export class DocDrawingPopupMenuController extends RxDisposable {
|
||||
this._univerInstanceService.getTypeOfUnitDisposed$<DocumentDataModel>(UniverInstanceType.UNIVER_DOC).pipe(takeUntil(this.dispose$)).subscribe((documentDataModel) => this._dispose(documentDataModel))
|
||||
);
|
||||
|
||||
this.disposeWithMe(
|
||||
this._renderManagerService.created$.pipe(takeUntil(this.dispose$)).subscribe((render) => {
|
||||
if (render.type !== UniverInstanceType.UNIVER_DOC || render.isMainScene) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._embeddedRenderUnits.add(render.unitId);
|
||||
this._create(this._univerInstanceService.getUnit<DocumentDataModel>(render.unitId, UniverInstanceType.UNIVER_DOC));
|
||||
})
|
||||
);
|
||||
|
||||
this.disposeWithMe(
|
||||
this._renderManagerService.disposed$.pipe(takeUntil(this.dispose$)).subscribe((unitId) => {
|
||||
if (!this._embeddedRenderUnits.delete(unitId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._disposePopupMenuListener(unitId);
|
||||
})
|
||||
);
|
||||
|
||||
this._univerInstanceService.getAllUnitsForType<DocumentDataModel>(UniverInstanceType.UNIVER_DOC).forEach((documentDataModel) => this._create(documentDataModel));
|
||||
}
|
||||
|
||||
private _dispose(documentDataModel: DocumentDataModel) {
|
||||
const unitId = documentDataModel.getUnitId();
|
||||
this._clearPopups();
|
||||
this._disposePopupMenuListener(unitId);
|
||||
this._renderManagerService.removeRender(unitId);
|
||||
}
|
||||
|
||||
@@ -120,11 +145,21 @@ export class DocDrawingPopupMenuController extends RxDisposable {
|
||||
return;
|
||||
}
|
||||
if (this._renderManagerService.has(unitId) && !this._initImagePopupMenu.has(unitId)) {
|
||||
this._popupMenuListener(unitId);
|
||||
this._initImagePopupMenu.add(unitId);
|
||||
const listener = this._popupMenuListener(unitId);
|
||||
if (listener) {
|
||||
this._popupMenuListeners.set(unitId, listener);
|
||||
this._initImagePopupMenu.add(unitId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _disposePopupMenuListener(unitId: string) {
|
||||
this._popupMenuListeners.get(unitId)?.dispose();
|
||||
this._popupMenuListeners.delete(unitId);
|
||||
this._initImagePopupMenu.delete(unitId);
|
||||
this._clearPopups();
|
||||
}
|
||||
|
||||
private _hasCropObject(scene: Scene) {
|
||||
const objects = scene.getAllObjects();
|
||||
|
||||
@@ -138,7 +173,7 @@ export class DocDrawingPopupMenuController extends RxDisposable {
|
||||
}
|
||||
|
||||
// eslint-disable-next-line max-lines-per-function
|
||||
private _popupMenuListener(unitId: string) {
|
||||
private _popupMenuListener(unitId: string): IDisposable | undefined {
|
||||
const scene = this._renderManagerService.getRenderUnitById(unitId)?.scene;
|
||||
if (!scene) {
|
||||
return;
|
||||
@@ -149,8 +184,7 @@ export class DocDrawingPopupMenuController extends RxDisposable {
|
||||
}
|
||||
|
||||
const disposePopups: IDisposable[] = this._disposePopups;
|
||||
|
||||
this.disposeWithMe(
|
||||
const subscriptions = [
|
||||
transformer.createControl$.subscribe(() => {
|
||||
if (this._hasCropObject(scene)) {
|
||||
return;
|
||||
@@ -159,10 +193,7 @@ export class DocDrawingPopupMenuController extends RxDisposable {
|
||||
const selectedObjects = transformer.getSelectedObjectMap();
|
||||
disposePopups.forEach((dispose) => dispose.dispose());
|
||||
disposePopups.length = 0;
|
||||
if (this._isDrawingPanelOpen) {
|
||||
return;
|
||||
}
|
||||
if (selectedObjects.size > 1) {
|
||||
if (this._isDrawingPanelOpen || selectedObjects.size > 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -171,8 +202,7 @@ export class DocDrawingPopupMenuController extends RxDisposable {
|
||||
return;
|
||||
}
|
||||
|
||||
const oKey = object.oKey;
|
||||
const drawingParam = this._drawingManagerService.getDrawingOKey(oKey);
|
||||
const drawingParam = this._drawingManagerService.getDrawingOKey(object.oKey);
|
||||
if (
|
||||
!drawingParam ||
|
||||
drawingParam.drawingType === DrawingTypeEnum.DRAWING_DOM ||
|
||||
@@ -205,44 +235,29 @@ export class DocDrawingPopupMenuController extends RxDisposable {
|
||||
disposePopups.push(this.disposeWithMe(popup));
|
||||
|
||||
const focusDrawings = this._drawingManagerService.getFocusDrawings();
|
||||
|
||||
const alreadyFocused = focusDrawings.find((drawing) => drawing.unitId === unitId && drawing.subUnitId === subUnitId && drawing.drawingId === drawingId);
|
||||
|
||||
if (alreadyFocused) {
|
||||
return;
|
||||
if (!alreadyFocused) {
|
||||
this._drawingManagerService.focusDrawing([{ unitId, subUnitId, drawingId }]);
|
||||
}
|
||||
|
||||
this._drawingManagerService.focusDrawing([{
|
||||
unitId,
|
||||
subUnitId,
|
||||
drawingId,
|
||||
}]);
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
this.disposeWithMe(
|
||||
}),
|
||||
transformer.clearControl$.subscribe(() => {
|
||||
disposePopups.forEach((dispose) => dispose.dispose());
|
||||
disposePopups.length = 0;
|
||||
this._contextService.setContextValue(FOCUSING_COMMON_DRAWINGS, false);
|
||||
this._drawingManagerService.focusDrawing(null);
|
||||
})
|
||||
);
|
||||
this.disposeWithMe(
|
||||
}),
|
||||
transformer.changing$.subscribe(() => {
|
||||
disposePopups.forEach((dispose) => dispose.dispose());
|
||||
disposePopups.length = 0;
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
this.disposeWithMe(
|
||||
}),
|
||||
transformer.changeStart$.subscribe(() => {
|
||||
disposePopups.forEach((dispose) => dispose.dispose());
|
||||
disposePopups.length = 0;
|
||||
})
|
||||
);
|
||||
}),
|
||||
];
|
||||
const disposable = toDisposable(() => subscriptions.forEach((subscription) => subscription.unsubscribe()));
|
||||
this.disposeWithMe(disposable);
|
||||
return disposable;
|
||||
}
|
||||
|
||||
private _getDrawingPopupMenuItems(unitId: string, subUnitId: string, drawingId: string, drawingType: number) {
|
||||
|
||||
@@ -57,16 +57,22 @@ class TestRenderManagerService {
|
||||
hasViewport = true;
|
||||
viewportScrollX = 0;
|
||||
viewportScrollY = 0;
|
||||
isMainScene: boolean | undefined = true;
|
||||
canvasElement: { getBoundingClientRect: () => { left: number; top: number; width: number }; style: { width: string } } | null = {
|
||||
getBoundingClientRect: () => ({ left: 10, top: 20, width: 1000 }),
|
||||
style: { width: '1000px' },
|
||||
};
|
||||
|
||||
popupInjector = new Injector();
|
||||
scopedPopupService = new TestCanvasPopupService();
|
||||
getInjector = vi.fn(() => this.popupInjector);
|
||||
readonly onTransformChange$ = new EventSubject();
|
||||
readonly onScrollAfter$ = new EventSubject();
|
||||
|
||||
constructor() {
|
||||
this.popupInjector.add([ICanvasPopupService, { useValue: this.scopedPopupService as never }]);
|
||||
}
|
||||
|
||||
getRenderUnitById(unitId: string) {
|
||||
if (unitId === 'missing-doc') {
|
||||
return undefined;
|
||||
@@ -77,6 +83,7 @@ class TestRenderManagerService {
|
||||
|
||||
return {
|
||||
unitId,
|
||||
isMainScene: this.isMainScene,
|
||||
engine: {
|
||||
getCanvasElement: () => this.canvasElement,
|
||||
},
|
||||
@@ -208,19 +215,32 @@ describe('DocCanvasPopManagerService', () => {
|
||||
expect(anchorRect$?.value).toEqual({ left: 25, right: 175, top: 50, bottom: 80 });
|
||||
});
|
||||
|
||||
it('uses a scoped popup injector only for embedded document render units', () => {
|
||||
const { service, popupService, renderManagerService, univerInstanceService } = createService();
|
||||
it('keeps main-scene popups global and routes embedded render popups to the render scope', () => {
|
||||
const { service, popupService, renderManagerService } = createService();
|
||||
|
||||
service.attachPopupToRect({ left: 10, right: 110, top: 20, bottom: 40 }, { componentKey: 'normal-popup' }, 'doc-1');
|
||||
expect(popupService.popups.get('popup-1')?.connectorInjector).toBeUndefined();
|
||||
expect(renderManagerService.getInjector).not.toHaveBeenCalled();
|
||||
|
||||
univerInstanceService.embeddedUnitIds.add('doc-1');
|
||||
renderManagerService.isMainScene = false;
|
||||
service.attachPopupToRect({ left: 10, right: 110, top: 20, bottom: 40 }, { componentKey: 'embed-popup' }, 'doc-1');
|
||||
expect(popupService.popups.get('popup-2')?.connectorInjector).toBe(renderManagerService.popupInjector);
|
||||
expect(popupService.popups.size).toBe(1);
|
||||
expect(renderManagerService.scopedPopupService.popups.get('popup-1')?.componentKey).toBe('embed-popup');
|
||||
expect(renderManagerService.scopedPopupService.popups.get('popup-1')?.connectorInjector).toBe(renderManagerService.popupInjector);
|
||||
expect(renderManagerService.getInjector).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('uses embedded unit creation metadata when the render has no scene ownership flag', () => {
|
||||
const { service, popupService, renderManagerService, univerInstanceService } = createService();
|
||||
renderManagerService.isMainScene = undefined;
|
||||
univerInstanceService.embeddedUnitIds.add('doc-1');
|
||||
|
||||
service.attachPopupToRect({ left: 10, right: 110, top: 20, bottom: 40 }, { componentKey: 'embed-popup' }, 'doc-1');
|
||||
|
||||
expect(popupService.popups.size).toBe(0);
|
||||
expect(renderManagerService.scopedPopupService.popups.get('popup-1')?.componentKey).toBe('embed-popup');
|
||||
});
|
||||
|
||||
it('refreshes function-based rect popup anchors after scroll and rich text changes', () => {
|
||||
const { service, popupService, renderManagerService, commandService } = createService();
|
||||
const rect = { left: 10, right: 110, top: 20, bottom: 40 };
|
||||
|
||||
@@ -39,6 +39,7 @@ import { NORMAL_TEXT_SELECTION_PLUGIN_STYLE } from '@univerjs/engine-render';
|
||||
import { ComponentManager } from '@univerjs/ui';
|
||||
import { Subject } from 'rxjs';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { EmbedRuntimeFocusCoordinator, IDocEmbedRuntimeFocusCoordinator } from '../doc-embed-integration.service';
|
||||
import { DocCanvasPopManagerService } from '../doc-popup-manager.service';
|
||||
import { DocFloatMenuService } from '../float-menu.service';
|
||||
import { DocSelectionRenderService } from '../selection/doc-selection-render.service';
|
||||
@@ -86,7 +87,11 @@ const InertDocSelectionRenderServiceCtor = InertDocSelectionRenderService as unk
|
||||
const RecordingDocCanvasPopManagerServiceCtor = RecordingDocCanvasPopManagerService as unknown as typeof DocCanvasPopManagerService;
|
||||
const ActiveDocSelectionRenderServiceCtor = ActiveDocSelectionRenderService as unknown as typeof DocSelectionRenderService;
|
||||
|
||||
function createActiveFloatMenuHarness(unitId: string, body: ConstructorParameters<typeof DocumentDataModel>[0]['body']) {
|
||||
function createActiveFloatMenuHarness(
|
||||
unitId: string,
|
||||
body: ConstructorParameters<typeof DocumentDataModel>[0]['body'],
|
||||
runtimeFocusCoordinator?: EmbedRuntimeFocusCoordinator
|
||||
) {
|
||||
const injector = new Injector();
|
||||
injector.add([ILogService, { useClass: DesktopLogService }]);
|
||||
injector.add([IConfigService, { useClass: ConfigService }]);
|
||||
@@ -97,6 +102,9 @@ function createActiveFloatMenuHarness(unitId: string, body: ConstructorParameter
|
||||
injector.add([DocCanvasPopManagerService, { useClass: RecordingDocCanvasPopManagerServiceCtor }]);
|
||||
injector.add([ComponentManager]);
|
||||
injector.add([DocSelectionRenderService, { useClass: ActiveDocSelectionRenderServiceCtor }]);
|
||||
if (runtimeFocusCoordinator) {
|
||||
injector.add([IDocEmbedRuntimeFocusCoordinator, { useValue: runtimeFocusCoordinator }]);
|
||||
}
|
||||
injector.get(ICommandService).registerCommand(SetTextSelectionsOperation);
|
||||
const univerInstanceService = injector.get(IUniverInstanceService) as UniverInstanceService;
|
||||
univerInstanceService.__addUnit(new DocumentDataModel({ id: unitId, body }));
|
||||
@@ -109,6 +117,7 @@ function createActiveFloatMenuHarness(unitId: string, body: ConstructorParameter
|
||||
popupService: injector.get(DocCanvasPopManagerService) as unknown as RecordingDocCanvasPopManagerService,
|
||||
selectionManager,
|
||||
service,
|
||||
selectionRenderService: injector.get(DocSelectionRenderService) as unknown as ActiveDocSelectionRenderService,
|
||||
univerInstanceService,
|
||||
};
|
||||
}
|
||||
@@ -402,6 +411,48 @@ describe('DocFloatMenuService', () => {
|
||||
expect(popupService.ranges).toEqual(['0:10']);
|
||||
});
|
||||
|
||||
it('does not restore a stale host text toolbar when an embed child session ends', () => {
|
||||
const unitId = 'doc-embed-host-menu';
|
||||
const runtimeFocusCoordinator = new EmbedRuntimeFocusCoordinator();
|
||||
const { popupService, selectionManager, selectionRenderService, service } = createActiveFloatMenuHarness(unitId, {
|
||||
dataStream: 'Embed host document\r\n',
|
||||
paragraphs: [{ paragraphId: 'para_docs_ui_embed_host_menu', startIndex: 19 }],
|
||||
sectionBreaks: [],
|
||||
customRanges: [],
|
||||
tables: [],
|
||||
textRuns: [],
|
||||
}, runtimeFocusCoordinator);
|
||||
const selection = {
|
||||
textRanges: [{ startOffset: 0, endOffset: 10, collapsed: false }],
|
||||
rectRanges: [],
|
||||
segmentId: '',
|
||||
segmentPage: -1,
|
||||
style: NORMAL_TEXT_SELECTION_PLUGIN_STYLE,
|
||||
isEditing: true,
|
||||
};
|
||||
|
||||
selectionManager.__replaceTextRangesWithNoRefresh(selection, { unitId, subUnitId: unitId });
|
||||
expect(service.floatMenu).toMatchObject({ start: 0, end: 10 });
|
||||
|
||||
const lease = runtimeFocusCoordinator.acquireLease({
|
||||
embedId: 'docs-floating-board',
|
||||
role: 'child-session',
|
||||
hostUnitId: unitId,
|
||||
childUnitId: 'child-board',
|
||||
});
|
||||
expect(service.floatMenu).toBeNull();
|
||||
|
||||
lease.dispose();
|
||||
selectionManager.__replaceTextRangesWithNoRefresh(selection, { unitId, subUnitId: unitId });
|
||||
expect(service.floatMenu).toBeNull();
|
||||
|
||||
selectionRenderService.emitSelectionStart();
|
||||
selectionManager.__replaceTextRangesWithNoRefresh(selection, { unitId, subUnitId: unitId });
|
||||
|
||||
expect(service.floatMenu).toMatchObject({ start: 0, end: 10 });
|
||||
expect(popupService.ranges).toEqual(['0:10', '0:10']);
|
||||
});
|
||||
|
||||
it('places the floating toolbar below a forward selection that spans multiple lines', () => {
|
||||
const unitId = 'doc-direction-menu';
|
||||
const { popupService, selectionManager, service } = createActiveFloatMenuHarness(unitId, {
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
*/
|
||||
|
||||
import type { IDisposable } from '@univerjs/core';
|
||||
import type { Observable } from 'rxjs';
|
||||
import { toDisposable } from '@univerjs/core';
|
||||
import { Subject } from 'rxjs';
|
||||
|
||||
@@ -28,6 +29,7 @@ export interface IDocEmbedInteractionBoundaryService {
|
||||
}
|
||||
|
||||
export interface IDocEmbedRuntimeFocusCoordinator {
|
||||
readonly runtimeSessionChanged$: Observable<void>;
|
||||
isChildUnitRuntimeEvent(unitId: string | undefined, target: EventTarget | null | undefined, event?: Event): boolean;
|
||||
isChildUnitInActiveSession(unitId: string | undefined): boolean;
|
||||
shouldSuppressHostInteraction(unitId: string | undefined, target?: EventTarget | null, event?: Event): boolean;
|
||||
|
||||
@@ -280,9 +280,10 @@ export class DocCanvasPopManagerService extends Disposable {
|
||||
throw new Error(`Current render not found, unitId: ${unitId}`);
|
||||
}
|
||||
const popupInjector = this._resolveEmbeddedPopupInjector(unitId, currentRender);
|
||||
const popupManagerService = this._resolvePopupManagerService(popupInjector);
|
||||
|
||||
const { position, position$, disposable } = this._createRectPositionObserver(rect, currentRender);
|
||||
const id = this._globalPopupManagerService.addPopup({
|
||||
const id = popupManagerService.addPopup({
|
||||
...popup,
|
||||
unitId,
|
||||
subUnitId: 'default',
|
||||
@@ -294,11 +295,11 @@ export class DocCanvasPopManagerService extends Disposable {
|
||||
|
||||
return {
|
||||
dispose: () => {
|
||||
this._globalPopupManagerService.removePopup(id);
|
||||
popupManagerService.removePopup(id);
|
||||
position$.complete();
|
||||
disposable.dispose();
|
||||
},
|
||||
canDispose: () => this._globalPopupManagerService.activePopupId !== id,
|
||||
canDispose: () => popupManagerService.activePopupId !== id,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -315,9 +316,10 @@ export class DocCanvasPopManagerService extends Disposable {
|
||||
throw new Error(`Current render not found, unitId: ${unitId}`);
|
||||
}
|
||||
const popupInjector = this._resolveEmbeddedPopupInjector(unitId, currentRender);
|
||||
const popupManagerService = this._resolvePopupManagerService(popupInjector);
|
||||
|
||||
const { position, position$, disposable } = this._createObjectPositionObserver(targetObject, currentRender);
|
||||
const id = this._globalPopupManagerService.addPopup({
|
||||
const id = popupManagerService.addPopup({
|
||||
...popup,
|
||||
unitId,
|
||||
subUnitId: 'default',
|
||||
@@ -329,11 +331,11 @@ export class DocCanvasPopManagerService extends Disposable {
|
||||
|
||||
return {
|
||||
dispose: () => {
|
||||
this._globalPopupManagerService.removePopup(id);
|
||||
popupManagerService.removePopup(id);
|
||||
position$.complete();
|
||||
disposable.dispose();
|
||||
},
|
||||
canDispose: () => this._globalPopupManagerService.activePopupId !== id,
|
||||
canDispose: () => popupManagerService.activePopupId !== id,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -357,11 +359,12 @@ export class DocCanvasPopManagerService extends Disposable {
|
||||
throw new Error(`Current render not found, unitId: ${unitId}`);
|
||||
}
|
||||
const popupInjector = this._resolveEmbeddedPopupInjector(unitId, currentRender);
|
||||
const popupManagerService = this._resolvePopupManagerService(popupInjector);
|
||||
|
||||
const { positions: bounds, positions$: bounds$, disposable } = this._createRangePositionObserver(range, currentRender);
|
||||
const position$ = bounds$.pipe(map((bounds) => direction.includes('top') ? bounds[0] : bounds[bounds.length - 1]));
|
||||
|
||||
const id = this._globalPopupManagerService.addPopup({
|
||||
const id = popupManagerService.addPopup({
|
||||
...popup,
|
||||
unitId,
|
||||
subUnitId: 'default',
|
||||
@@ -380,18 +383,26 @@ export class DocCanvasPopManagerService extends Disposable {
|
||||
|
||||
return {
|
||||
dispose: () => {
|
||||
this._globalPopupManagerService.removePopup(id);
|
||||
popupManagerService.removePopup(id);
|
||||
bounds$.complete();
|
||||
disposable.dispose();
|
||||
},
|
||||
canDispose: () => this._globalPopupManagerService.activePopupId !== id,
|
||||
canDispose: () => popupManagerService.activePopupId !== id,
|
||||
};
|
||||
}
|
||||
// #endregion
|
||||
|
||||
private _resolveEmbeddedPopupInjector(unitId: string, currentRender: IRender): Injector | undefined {
|
||||
return this._univerInstanceService.getUnitCreateOptions(unitId)?.embeddedRender === true
|
||||
const isEmbeddedRender = currentRender.isMainScene === false ||
|
||||
this._univerInstanceService.getUnitCreateOptions(unitId)?.embeddedRender === true;
|
||||
return isEmbeddedRender
|
||||
? currentRender.getInjector?.()
|
||||
: undefined;
|
||||
}
|
||||
|
||||
private _resolvePopupManagerService(popupInjector: Injector | undefined): ICanvasPopupService {
|
||||
return popupInjector?.has(ICanvasPopupService)
|
||||
? popupInjector.get(ICanvasPopupService)
|
||||
: this._globalPopupManagerService;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,11 +26,13 @@ import {
|
||||
Inject,
|
||||
isInternalEditorID,
|
||||
IUniverInstanceService,
|
||||
Optional,
|
||||
toDisposable,
|
||||
UniverInstanceType,
|
||||
} from '@univerjs/core';
|
||||
import { DocSelectionManagerService } from '@univerjs/docs';
|
||||
import { FLOAT_MENU_COMPONENT_KEY } from '../views/float-toolbar/FloatToolbar';
|
||||
import { IDocEmbedRuntimeFocusCoordinator } from './doc-embed-integration.service';
|
||||
import { DocCanvasPopManagerService } from './doc-popup-manager.service';
|
||||
import { DocSelectionRenderService } from './selection/doc-selection-render.service';
|
||||
|
||||
@@ -52,6 +54,8 @@ const SKIP_SYMBOLS: string[] = [
|
||||
export class DocFloatMenuService extends Disposable implements IRenderModule {
|
||||
private _floatMenu: Nullable<{ disposable: IDisposable; start: number; end: number }> = null;
|
||||
private _suppressed = false;
|
||||
private _embedSuppressed = false;
|
||||
private _invalidatedSelection: Nullable<string> = null;
|
||||
|
||||
constructor(
|
||||
private _context: IRenderContext<DocumentDataModel>,
|
||||
@@ -59,7 +63,8 @@ export class DocFloatMenuService extends Disposable implements IRenderModule {
|
||||
@Inject(DocCanvasPopManagerService) private readonly _docCanvasPopManagerService: DocCanvasPopManagerService,
|
||||
@Inject(IUniverInstanceService) private readonly _univerInstanceService: IUniverInstanceService,
|
||||
@Inject(DocSelectionRenderService) private readonly _docSelectionRenderService: DocSelectionRenderService,
|
||||
@IContextService private readonly _contextService: IContextService
|
||||
@IContextService private readonly _contextService: IContextService,
|
||||
@Optional(IDocEmbedRuntimeFocusCoordinator) private readonly _embedRuntimeFocusCoordinator?: IDocEmbedRuntimeFocusCoordinator
|
||||
) {
|
||||
super();
|
||||
|
||||
@@ -67,6 +72,7 @@ export class DocFloatMenuService extends Disposable implements IRenderModule {
|
||||
return;
|
||||
}
|
||||
this._initSelectionChange();
|
||||
this._initEmbedRuntimeLifecycle();
|
||||
|
||||
this.disposeWithMe(() => {
|
||||
this._hideFloatMenu();
|
||||
@@ -94,6 +100,7 @@ export class DocFloatMenuService extends Disposable implements IRenderModule {
|
||||
|
||||
private _initSelectionChange() {
|
||||
this.disposeWithMe(this._docSelectionRenderService.onSelectionStart$.subscribe(() => {
|
||||
this._invalidatedSelection = null;
|
||||
this._hideFloatMenu();
|
||||
}));
|
||||
|
||||
@@ -103,13 +110,19 @@ export class DocFloatMenuService extends Disposable implements IRenderModule {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._suppressed || this._contextService.getContextValue(FOCUSING_COMMON_DRAWINGS)) {
|
||||
if (this._suppressed || this._embedSuppressed || this._contextService.getContextValue(FOCUSING_COMMON_DRAWINGS)) {
|
||||
this._hideFloatMenu();
|
||||
return;
|
||||
}
|
||||
|
||||
const range = (textRanges.length > 0) && textRanges.find((range) => !range.collapsed);
|
||||
if (range) {
|
||||
const selectionKey = this._getSelectionKey(range);
|
||||
if (selectionKey === this._invalidatedSelection) {
|
||||
this._hideFloatMenu();
|
||||
return;
|
||||
}
|
||||
this._invalidatedSelection = null;
|
||||
if (range.startOffset === this._floatMenu?.start && range.endOffset === this._floatMenu?.end) {
|
||||
return;
|
||||
}
|
||||
@@ -118,10 +131,44 @@ export class DocFloatMenuService extends Disposable implements IRenderModule {
|
||||
return;
|
||||
}
|
||||
|
||||
this._invalidatedSelection = null;
|
||||
this._hideFloatMenu();
|
||||
}));
|
||||
}
|
||||
|
||||
private _initEmbedRuntimeLifecycle(): void {
|
||||
if (!this._embedRuntimeFocusCoordinator) {
|
||||
return;
|
||||
}
|
||||
|
||||
const syncSuppressedState = () => {
|
||||
const suppressed = this._embedRuntimeFocusCoordinator?.shouldSuppressHostInteraction(this._context.unitId) === true;
|
||||
if (this._embedSuppressed === suppressed) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._embedSuppressed = suppressed;
|
||||
if (!suppressed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const expandedRange = this._docSelectionManagerService
|
||||
.getTextRanges({ unitId: this._context.unitId, subUnitId: this._context.unitId })
|
||||
?.find((range) => !range.collapsed);
|
||||
this._invalidatedSelection = expandedRange
|
||||
? this._getSelectionKey(expandedRange)
|
||||
: this._floatMenu && `${this._floatMenu.start}:${this._floatMenu.end}`;
|
||||
this._hideFloatMenu();
|
||||
};
|
||||
|
||||
this.disposeWithMe(this._embedRuntimeFocusCoordinator.runtimeSessionChanged$.subscribe(syncSuppressedState));
|
||||
syncSuppressedState();
|
||||
}
|
||||
|
||||
private _getSelectionKey(range: Pick<ITextRangeParam, 'startOffset' | 'endOffset'>): string {
|
||||
return `${range.startOffset}:${range.endOffset}`;
|
||||
}
|
||||
|
||||
private _hideFloatMenu() {
|
||||
this._floatMenu?.disposable.dispose();
|
||||
this._floatMenu = null;
|
||||
|
||||
+23
-4
@@ -245,10 +245,8 @@ describe('formula selection update helpers', () => {
|
||||
})),
|
||||
};
|
||||
const editor = {
|
||||
getDocumentData: vi.fn(() => ({
|
||||
body: {
|
||||
dataStream: '=SUM(\r\n',
|
||||
},
|
||||
getDocumentDataModel: vi.fn(() => ({
|
||||
getBody: () => ({ dataStream: '=SUM(\r\n' }),
|
||||
})),
|
||||
};
|
||||
|
||||
@@ -258,6 +256,27 @@ describe('formula selection update helpers', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the editor unit when the formula editor document model has been disposed', () => {
|
||||
const accessor = {
|
||||
get: vi.fn(() => ({
|
||||
getUnit: vi.fn((unitId: string) => unitId === 'formula-editor'
|
||||
? { getBody: () => ({ dataStream: '=A1\r\n' }) }
|
||||
: undefined),
|
||||
getCurrentUnitOfType: vi.fn(() => ({
|
||||
getBody: () => ({ dataStream: 'host document text\r\n' }),
|
||||
})),
|
||||
})),
|
||||
};
|
||||
const editor = {
|
||||
getDocumentDataModel: vi.fn(() => null),
|
||||
};
|
||||
|
||||
expect(resolveFormulaSelectionDataStream(accessor as never, editor as never, 'formula-editor')).toEqual({
|
||||
dataStream: '=A1\r\n',
|
||||
offset: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('reads formula selection text from the editor unit before falling back to the current host document', () => {
|
||||
const accessor = {
|
||||
get: vi.fn(() => ({
|
||||
|
||||
@@ -28,8 +28,8 @@ import { filter } from 'rxjs';
|
||||
import { RefSelectionsRenderService } from '../../../services/render-services/ref-selections.render.service';
|
||||
import { useStateRef } from './use-state-ref';
|
||||
|
||||
export function resolveFormulaSelectionDataStream(accssor: IAccessor, editor?: Pick<Editor, 'getDocumentData'>, editorId?: string) {
|
||||
const editorDataStream = editor?.getDocumentData().body?.dataStream;
|
||||
export function resolveFormulaSelectionDataStream(accssor: IAccessor, editor?: Pick<Editor, 'getDocumentDataModel'>, editorId?: string) {
|
||||
const editorDataStream = editor?.getDocumentDataModel()?.getBody()?.dataStream;
|
||||
if (editorDataStream != null) {
|
||||
return { dataStream: editorDataStream, offset: 0 };
|
||||
}
|
||||
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* 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 { IWorkbookData } from '@univerjs/core';
|
||||
import { IUniverInstanceService, LocaleType, toDisposable, Univer, UniverInstanceType } from '@univerjs/core';
|
||||
import { SheetsNoteModel } from '@univerjs/sheets-note';
|
||||
import { CellPopupManagerService } from '@univerjs/sheets-ui';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { SheetsNotePopupService } from '../../services/sheets-note-popup.service';
|
||||
import { SheetsNoteAttachmentController } from '../sheets-note-attachment.controller';
|
||||
|
||||
const WORKBOOK_DATA: IWorkbookData = {
|
||||
id: 'note-host',
|
||||
appVersion: '3.0.0-alpha',
|
||||
name: 'Note host',
|
||||
locale: LocaleType.EN_US,
|
||||
sheetOrder: ['sheet-1'],
|
||||
styles: {},
|
||||
sheets: {
|
||||
'sheet-1': {
|
||||
id: 'sheet-1',
|
||||
name: 'Sheet 1',
|
||||
cellData: {},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
class RecordingCellPopupManagerService {
|
||||
static activeCount = 0;
|
||||
|
||||
showPopup() {
|
||||
RecordingCellPopupManagerService.activeCount += 1;
|
||||
return toDisposable(() => {
|
||||
RecordingCellPopupManagerService.activeCount -= 1;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
describe('SheetsNoteAttachmentController', () => {
|
||||
let univer: Univer;
|
||||
|
||||
beforeEach(() => {
|
||||
RecordingCellPopupManagerService.activeCount = 0;
|
||||
univer = new Univer();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
univer.dispose();
|
||||
});
|
||||
|
||||
it('hides persistent Notes while a host is suppressed and restores current model state afterwards', () => {
|
||||
const injector = univer.__getInjector();
|
||||
injector.add([SheetsNoteModel]);
|
||||
injector.add([CellPopupManagerService, { useClass: RecordingCellPopupManagerService as never }]);
|
||||
injector.add([SheetsNotePopupService]);
|
||||
injector.add([SheetsNoteAttachmentController]);
|
||||
univer.createUnit(UniverInstanceType.UNIVER_SHEET, WORKBOOK_DATA);
|
||||
injector.get(IUniverInstanceService).focusUnit(WORKBOOK_DATA.id);
|
||||
|
||||
const noteModel = injector.get(SheetsNoteModel);
|
||||
noteModel.updateNote(WORKBOOK_DATA.id, 'sheet-1', 1, 2, {
|
||||
id: 'note-1',
|
||||
width: 160,
|
||||
height: 60,
|
||||
note: 'First note',
|
||||
show: true,
|
||||
});
|
||||
const controller = injector.get(SheetsNoteAttachmentController);
|
||||
expect(RecordingCellPopupManagerService.activeCount).toBe(1);
|
||||
|
||||
controller.setPopupSuppressed(WORKBOOK_DATA.id, true);
|
||||
expect(RecordingCellPopupManagerService.activeCount).toBe(0);
|
||||
|
||||
noteModel.updateNote(WORKBOOK_DATA.id, 'sheet-1', 3, 4, {
|
||||
id: 'note-2',
|
||||
width: 160,
|
||||
height: 60,
|
||||
note: 'Second note',
|
||||
show: true,
|
||||
});
|
||||
expect(RecordingCellPopupManagerService.activeCount).toBe(0);
|
||||
|
||||
controller.setPopupSuppressed(WORKBOOK_DATA.id, false);
|
||||
expect(RecordingCellPopupManagerService.activeCount).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,7 @@
|
||||
import type { IDisposable, Nullable, Workbook } from '@univerjs/core';
|
||||
import type { ISheetLocationBase } from '@univerjs/sheets';
|
||||
import type { ISheetNote } from '@univerjs/sheets-note';
|
||||
import type { Subscription } from 'rxjs';
|
||||
import { Disposable, Inject, IUniverInstanceService, ObjectMatrix, UniverInstanceType } from '@univerjs/core';
|
||||
import { SheetsNoteModel } from '@univerjs/sheets-note';
|
||||
import { CellPopupManagerService } from '@univerjs/sheets-ui';
|
||||
@@ -26,6 +27,10 @@ import { SHEET_NOTE_COMPONENT } from '../views/config';
|
||||
|
||||
export class SheetsNoteAttachmentController extends Disposable {
|
||||
private _noteMatrix = new ObjectMatrix<IDisposable>();
|
||||
private readonly _suppressedUnitIds = new Set<string>();
|
||||
private _activeUnitId: Nullable<string> = null;
|
||||
private _activeSheetId: Nullable<string> = null;
|
||||
private _noteChangeSubscription: Nullable<Subscription> = null;
|
||||
|
||||
constructor(
|
||||
@Inject(SheetsNoteModel) private readonly _sheetsNoteModel: SheetsNoteModel,
|
||||
@@ -66,22 +71,45 @@ export class SheetsNoteAttachmentController extends Disposable {
|
||||
|
||||
override dispose(): void {
|
||||
super.dispose();
|
||||
this._noteMatrix.forValue((_, __, disposable) => {
|
||||
disposable.dispose();
|
||||
});
|
||||
this._clearNoteMatrix();
|
||||
this._suppressedUnitIds.clear();
|
||||
this._activeUnitId = null;
|
||||
this._activeSheetId = null;
|
||||
this._noteChangeSubscription?.unsubscribe();
|
||||
this._noteChangeSubscription = null;
|
||||
}
|
||||
|
||||
setPopupSuppressed(unitId: string, suppressed: boolean): void {
|
||||
if (suppressed) {
|
||||
this._suppressedUnitIds.add(unitId);
|
||||
} else {
|
||||
this._suppressedUnitIds.delete(unitId);
|
||||
}
|
||||
|
||||
if (unitId !== this._activeUnitId || !this._activeSheetId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (suppressed) {
|
||||
this._clearNoteMatrix();
|
||||
} else {
|
||||
this._showPersistentNotes(unitId, this._activeSheetId);
|
||||
}
|
||||
}
|
||||
|
||||
private _initSheet(targetUnitId: string, targetSheetId: string) {
|
||||
const oldMatrix = this._noteMatrix;
|
||||
oldMatrix.forValue((_, __, disposable) => {
|
||||
disposable.dispose();
|
||||
});
|
||||
|
||||
this._noteMatrix = new ObjectMatrix();
|
||||
this._clearNoteMatrix();
|
||||
this._activeUnitId = targetUnitId;
|
||||
this._activeSheetId = targetSheetId;
|
||||
const handleNote = (unitId: string, sheetId: string, row: number, col: number, note: Nullable<ISheetNote>) => {
|
||||
const matrix = this._noteMatrix;
|
||||
const disposable = matrix.getValue(row, col);
|
||||
if (note?.show) {
|
||||
if (this._suppressedUnitIds.has(unitId)) {
|
||||
disposable?.dispose();
|
||||
matrix.realDeleteValue(row, col);
|
||||
return;
|
||||
}
|
||||
if (!disposable) {
|
||||
const newDisposable = this._showPopup(unitId, sheetId, row, col);
|
||||
if (newDisposable) {
|
||||
@@ -96,9 +124,9 @@ export class SheetsNoteAttachmentController extends Disposable {
|
||||
}
|
||||
};
|
||||
|
||||
this._sheetsNoteModel.getSheetNotes(targetUnitId, targetSheetId)?.forEach((note) => {
|
||||
handleNote(targetUnitId, targetSheetId, note.row, note.col, note);
|
||||
});
|
||||
if (!this._suppressedUnitIds.has(targetUnitId)) {
|
||||
this._showPersistentNotes(targetUnitId, targetSheetId);
|
||||
}
|
||||
|
||||
return this._sheetsNoteModel.change$.subscribe((change) => {
|
||||
if (change.unitId !== targetUnitId || change.subUnitId !== targetSheetId) {
|
||||
@@ -118,10 +146,7 @@ export class SheetsNoteAttachmentController extends Disposable {
|
||||
disposable.dispose();
|
||||
matrix.realDeleteValue(oldRow, oldCol);
|
||||
}
|
||||
const newDisposable = this._showPopup(unitId, subUnitId, newRow, newCol);
|
||||
if (newDisposable) {
|
||||
matrix.setValue(newRow, newCol, newDisposable);
|
||||
}
|
||||
handleNote(unitId, subUnitId, newRow, newCol, newNote);
|
||||
break;
|
||||
}
|
||||
case 'update': {
|
||||
@@ -142,18 +167,36 @@ export class SheetsNoteAttachmentController extends Disposable {
|
||||
this._univerInstanceService.getCurrentTypeOfUnit$<Workbook>(UniverInstanceType.UNIVER_SHEET).pipe(
|
||||
switchMap((workbook) => workbook?.activeSheet$ ?? of(null))
|
||||
).subscribe((sheet) => {
|
||||
this._noteChangeSubscription?.unsubscribe();
|
||||
this._noteChangeSubscription = null;
|
||||
if (sheet) {
|
||||
const disposable = this._initSheet(sheet.getUnitId(), sheet.getSheetId());
|
||||
return () => {
|
||||
disposable.unsubscribe();
|
||||
};
|
||||
this._noteChangeSubscription = this._initSheet(sheet.getUnitId(), sheet.getSheetId());
|
||||
} else {
|
||||
this._noteMatrix.forValue((_, __, disposable) => {
|
||||
disposable.dispose();
|
||||
});
|
||||
this._noteMatrix = new ObjectMatrix();
|
||||
this._clearNoteMatrix();
|
||||
this._activeUnitId = null;
|
||||
this._activeSheetId = null;
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private _showPersistentNotes(unitId: string, sheetId: string): void {
|
||||
this._sheetsNoteModel.getSheetNotes(unitId, sheetId)?.forEach((note) => {
|
||||
if (!note.show || this._noteMatrix.getValue(note.row, note.col)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const disposable = this._showPopup(unitId, sheetId, note.row, note.col);
|
||||
if (disposable) {
|
||||
this._noteMatrix.setValue(note.row, note.col, disposable);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private _clearNoteMatrix(): void {
|
||||
this._noteMatrix.forValue((_, __, disposable) => {
|
||||
disposable.dispose();
|
||||
});
|
||||
this._noteMatrix = new ObjectMatrix();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import './global.css';
|
||||
|
||||
export type { IUniverSheetsNoteUIConfig } from './config/config';
|
||||
export { SheetsCellContentController } from './controllers/sheets-cell-content.controller';
|
||||
export { SheetsNoteAttachmentController } from './controllers/sheets-note-attachment.controller';
|
||||
export { SheetsNotePopupController } from './controllers/sheets-note-popup.controller';
|
||||
export { menuSchema as SheetsNoteUIMenuSchema } from './menu/schema';
|
||||
export { UniverSheetsNoteUIPlugin } from './plugin';
|
||||
|
||||
+24
-1
@@ -16,7 +16,7 @@
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ISheetHostChromeOverrideService } from '../../../services/sheet-host-chrome-override.service';
|
||||
import { shouldSuppressSheetContextMenuForEmbedOverride } from '../contextmenu.render-controller';
|
||||
import { shouldHideSheetHostContextMenuForEmbedSession, shouldSuppressSheetContextMenuForEmbedOverride } from '../contextmenu.render-controller';
|
||||
|
||||
describe('SheetContextMenuRenderController embed chrome bridge', () => {
|
||||
it('suppresses host sheet context menus only for active sheet-tab overrides', () => {
|
||||
@@ -37,4 +37,27 @@ describe('SheetContextMenuRenderController embed chrome bridge', () => {
|
||||
it('uses a sheets-ui owned host chrome override service token', () => {
|
||||
expect(ISheetHostChromeOverrideService).toBeTruthy();
|
||||
});
|
||||
|
||||
it('closes an open host context menu whenever a child session takes ownership', () => {
|
||||
expect(shouldHideSheetHostContextMenuForEmbedSession('host-1', {
|
||||
embedId: 'embed-1',
|
||||
hostUnitId: 'host-1',
|
||||
sessionMode: 'child-keyboard',
|
||||
})).toBe(true);
|
||||
expect(shouldHideSheetHostContextMenuForEmbedSession('host-1', {
|
||||
embedId: 'embed-1',
|
||||
hostUnitId: 'host-1',
|
||||
sessionMode: 'child-fullscreen',
|
||||
})).toBe(true);
|
||||
expect(shouldHideSheetHostContextMenuForEmbedSession('host-1', {
|
||||
embedId: 'embed-1',
|
||||
hostUnitId: 'host-2',
|
||||
sessionMode: 'child-fullscreen',
|
||||
})).toBe(false);
|
||||
expect(shouldHideSheetHostContextMenuForEmbedSession('host-1', {
|
||||
embedId: 'embed-1',
|
||||
hostUnitId: 'host-1',
|
||||
sessionMode: 'host-passive',
|
||||
})).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
+38
-1
@@ -79,6 +79,7 @@ function createController(options?: {
|
||||
const selectionMoveStart$ = new Subject<any>();
|
||||
const selectionSet$ = new Subject<any>();
|
||||
const inputBefore$ = new Subject<any>();
|
||||
const renderCreated$ = new Subject<unknown>();
|
||||
const spreadsheet = {
|
||||
onDblclick$: createEventSubject(),
|
||||
onPointerDown$: createEventSubject(),
|
||||
@@ -119,7 +120,7 @@ function createController(options?: {
|
||||
setInputPosition: vi.fn(),
|
||||
};
|
||||
const renderManagerService = {
|
||||
created$: new Subject<any>(),
|
||||
created$: renderCreated$,
|
||||
getRenderUnitById: vi.fn((unitId: string) => unitId === DOCS_NORMAL_EDITOR_UNIT_ID_KEY
|
||||
? {
|
||||
unitId,
|
||||
@@ -205,6 +206,7 @@ function createController(options?: {
|
||||
docSelectionRenderService,
|
||||
editorBridgeService,
|
||||
inputBefore$,
|
||||
renderCreated$,
|
||||
selectionMoveEnd$,
|
||||
selectionSet$,
|
||||
spreadsheet,
|
||||
@@ -372,6 +374,41 @@ describe('EditorBridgeRenderController business flows', () => {
|
||||
controller.dispose();
|
||||
});
|
||||
|
||||
it('continues opening sheet editing after the internal editor render is recreated', () => {
|
||||
const { commandService, controller, inputBefore$, renderCreated$, workbook$ } = createController();
|
||||
const recreatedInputBefore$ = new Subject<unknown>();
|
||||
const createRender = (input$: Subject<unknown>) => ({
|
||||
unitId: DOCS_NORMAL_EDITOR_UNIT_ID_KEY,
|
||||
with: vi.fn((token: unknown) => token === DocSelectionRenderService
|
||||
? { onInputBefore$: input$ }
|
||||
: null),
|
||||
});
|
||||
|
||||
inputBefore$.next({ event: { data: 'A', which: 65 } });
|
||||
renderCreated$.next(createRender(recreatedInputBefore$));
|
||||
inputBefore$.next({ event: { data: 'B', which: 66 } });
|
||||
recreatedInputBefore$.next({ event: { data: 'C', which: 67 } });
|
||||
|
||||
expect(commandService.syncExecuteCommand).toHaveBeenCalledTimes(2);
|
||||
expect(commandService.syncExecuteCommand).toHaveBeenLastCalledWith(SetCellEditVisibleOperation.id, {
|
||||
visible: true,
|
||||
eventType: DeviceInputEventType.Keyboard,
|
||||
keycode: 67,
|
||||
initialValue: 'C',
|
||||
unitId: 'unit-1',
|
||||
});
|
||||
|
||||
renderCreated$.next(createRender(recreatedInputBefore$));
|
||||
recreatedInputBefore$.next({ event: { data: 'D', which: 68 } });
|
||||
expect(commandService.syncExecuteCommand).toHaveBeenCalledTimes(3);
|
||||
|
||||
workbook$.next(null);
|
||||
recreatedInputBefore$.next({ event: { data: 'E', which: 69 } });
|
||||
expect(commandService.syncExecuteCommand).toHaveBeenCalledTimes(3);
|
||||
|
||||
controller.dispose();
|
||||
});
|
||||
|
||||
it('hides a visible editor from sheet pointer actions but keeps it while formula range input is active', () => {
|
||||
const keepVisible = createController({ editorVisible: true, forceKeepVisible: true });
|
||||
keepVisible.spreadsheet.onPointerDown$.emit({});
|
||||
|
||||
+32
@@ -16,6 +16,7 @@
|
||||
|
||||
import type { Workbook } from '@univerjs/core';
|
||||
import type { IRenderContext, IRenderModule, Spreadsheet, SpreadsheetColumnHeader, SpreadsheetHeader } from '@univerjs/engine-render';
|
||||
import type { ISheetEmbedRuntimeDomScope } from '../../services/sheet-embed-integration.service';
|
||||
import type { ISheetHostChromeOverride } from '../../services/sheet-host-chrome-override.service';
|
||||
import {
|
||||
Disposable,
|
||||
@@ -27,6 +28,7 @@ import { attachSelectionWithCoord, SheetsSelectionsService } from '@univerjs/she
|
||||
import { ContextMenuPosition, IContextMenuService } from '@univerjs/ui';
|
||||
import { SHEET_VIEW_KEY } from '../../common/keys';
|
||||
import { ISheetSelectionRenderService } from '../../services/selection/base-selection-render.service';
|
||||
import { ISheetEmbedRuntimeFocusCoordinator } from '../../services/sheet-embed-integration.service';
|
||||
import { ISheetHostChromeOverrideService } from '../../services/sheet-host-chrome-override.service';
|
||||
|
||||
/**
|
||||
@@ -44,6 +46,7 @@ export class SheetContextMenuRenderController extends Disposable implements IRen
|
||||
super();
|
||||
|
||||
this._init();
|
||||
this._initEmbedRuntimeSessionListener();
|
||||
}
|
||||
|
||||
private _init(): void {
|
||||
@@ -122,6 +125,22 @@ export class SheetContextMenuRenderController extends Disposable implements IRen
|
||||
this.disposeWithMe(colHeaderObserver);
|
||||
}
|
||||
|
||||
private _initEmbedRuntimeSessionListener(): void {
|
||||
const runtimeFocusCoordinator = this._getSheetEmbedRuntimeFocusCoordinator();
|
||||
if (!runtimeFocusCoordinator) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.disposeWithMe(runtimeFocusCoordinator.runtimeSessionChanged$.subscribe(() => {
|
||||
if (shouldHideSheetHostContextMenuForEmbedSession(
|
||||
this._context.unitId,
|
||||
runtimeFocusCoordinator.resolveActiveChildSessionRuntimeScope()
|
||||
)) {
|
||||
this._contextMenuService.hideContextMenu();
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
private _shouldSuppressHostContextMenu(): boolean {
|
||||
return shouldSuppressSheetContextMenuForEmbedOverride(
|
||||
this._context.unitId,
|
||||
@@ -134,6 +153,19 @@ export class SheetContextMenuRenderController extends Disposable implements IRen
|
||||
? this._injector.get(ISheetHostChromeOverrideService)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
private _getSheetEmbedRuntimeFocusCoordinator(): ISheetEmbedRuntimeFocusCoordinator | undefined {
|
||||
return this._injector.has(ISheetEmbedRuntimeFocusCoordinator)
|
||||
? this._injector.get(ISheetEmbedRuntimeFocusCoordinator)
|
||||
: undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldHideSheetHostContextMenuForEmbedSession(
|
||||
hostUnitId: string,
|
||||
activeScope: ISheetEmbedRuntimeDomScope | undefined
|
||||
): boolean {
|
||||
return activeScope?.hostUnitId === hostUnitId && activeScope.sessionMode !== 'host-passive';
|
||||
}
|
||||
|
||||
export function shouldSuppressSheetContextMenuForEmbedOverride(
|
||||
|
||||
+9
-7
@@ -214,6 +214,9 @@ export class EditorBridgeRenderController extends RxDisposable implements IRende
|
||||
private _initialKeyboardListener(d: DisposableCollection) {
|
||||
let disposable: Nullable<IDisposable> = null;
|
||||
const addEvent = (render: IRender) => {
|
||||
disposable?.dispose();
|
||||
disposable = null;
|
||||
|
||||
const docSelectionRenderService = render.with(DocSelectionRenderService);
|
||||
if (docSelectionRenderService) {
|
||||
disposable = toDisposable(docSelectionRenderService.onInputBefore$.subscribe((config) => {
|
||||
@@ -229,20 +232,19 @@ export class EditorBridgeRenderController extends RxDisposable implements IRende
|
||||
this._showEditorByKeyboard(config);
|
||||
}
|
||||
}));
|
||||
|
||||
d.add(disposable);
|
||||
}
|
||||
};
|
||||
|
||||
const render = this._renderManagerService.getRenderUnitById(DOCS_NORMAL_EDITOR_UNIT_ID_KEY);
|
||||
if (render) {
|
||||
addEvent(render);
|
||||
} else {
|
||||
this.disposeWithMe(this._renderManagerService.created$.pipe(filter((render) => render.unitId === DOCS_NORMAL_EDITOR_UNIT_ID_KEY)).subscribe((render) => {
|
||||
disposable?.dispose();
|
||||
addEvent(render);
|
||||
}));
|
||||
}
|
||||
|
||||
d.add(this._renderManagerService.created$.pipe(filter((render) => render.unitId === DOCS_NORMAL_EDITOR_UNIT_ID_KEY)).subscribe(addEvent));
|
||||
d.add(toDisposable(() => {
|
||||
disposable?.dispose();
|
||||
disposable = null;
|
||||
}));
|
||||
}
|
||||
|
||||
private _initSheetFocusListener(d: DisposableCollection) {
|
||||
|
||||
@@ -29,11 +29,14 @@ export const SHEET_EMBED_HOST_UNIT_ID_ATTRIBUTE = 'data-embed-host-unit-id';
|
||||
export const SHEET_EMBED_CHILD_UNIT_ID_ATTRIBUTE = 'data-embed-child-unit-id';
|
||||
export const SHEET_EMBED_CHILD_TYPE_ATTRIBUTE = 'data-embed-child-type';
|
||||
|
||||
export type SheetEmbedRuntimeSessionMode = 'host-passive' | 'child-keyboard' | 'child-fullscreen' | 'child-tab';
|
||||
|
||||
export interface ISheetEmbedRuntimeDomScope {
|
||||
embedId: string;
|
||||
hostUnitId?: string;
|
||||
childUnitId?: string;
|
||||
childType?: UniverInstanceType;
|
||||
sessionMode?: SheetEmbedRuntimeSessionMode;
|
||||
}
|
||||
|
||||
export interface ISheetEmbedInteractionBoundaryService {
|
||||
@@ -52,6 +55,7 @@ export interface ISheetEmbedRuntimeFocusCoordinator {
|
||||
childUnitId?: string;
|
||||
childType?: UniverInstanceType;
|
||||
associatedChildUnitIds?: string[];
|
||||
sessionMode?: SheetEmbedRuntimeSessionMode;
|
||||
}): IDisposable;
|
||||
registerElement(options: {
|
||||
embedId: string;
|
||||
@@ -128,6 +132,7 @@ export class EmbedRuntimeFocusCoordinator implements ISheetEmbedRuntimeFocusCoor
|
||||
hostUnitId?: string;
|
||||
childType?: UniverInstanceType;
|
||||
associatedChildUnitIds?: string[];
|
||||
sessionMode?: SheetEmbedRuntimeSessionMode;
|
||||
}>();
|
||||
|
||||
private readonly _elements = new Map<string, Set<HTMLElement>>();
|
||||
@@ -141,6 +146,7 @@ export class EmbedRuntimeFocusCoordinator implements ISheetEmbedRuntimeFocusCoor
|
||||
hostUnitId?: string;
|
||||
childType?: UniverInstanceType;
|
||||
associatedChildUnitIds?: string[];
|
||||
sessionMode?: SheetEmbedRuntimeSessionMode;
|
||||
}): IDisposable {
|
||||
this._leases.add(options);
|
||||
if (options.role === 'child-session') {
|
||||
@@ -176,7 +182,7 @@ export class EmbedRuntimeFocusCoordinator implements ISheetEmbedRuntimeFocusCoor
|
||||
return !!element && this._elements.get(embedId)?.has(element) === true;
|
||||
}
|
||||
|
||||
registerRuntimeScope(options: { embedId: string; hostUnitId?: string; childUnitId?: string; childType?: UniverInstanceType }): IDisposable {
|
||||
registerRuntimeScope(options: { embedId: string; hostUnitId?: string; childUnitId?: string; childType?: UniverInstanceType; sessionMode?: SheetEmbedRuntimeSessionMode }): IDisposable {
|
||||
const lease = { ...options, role: 'runtime' };
|
||||
this._leases.add(lease);
|
||||
this.runtimeSessionChanged$.next();
|
||||
@@ -197,12 +203,12 @@ export class EmbedRuntimeFocusCoordinator implements ISheetEmbedRuntimeFocusCoor
|
||||
|
||||
resolveRuntimeScopeByChildUnitId(childUnitId: string | undefined): ISheetEmbedRuntimeDomScope | undefined {
|
||||
const lease = [...this._leases].find((item) => matchesChildUnitId(item, childUnitId));
|
||||
return lease ? { embedId: lease.embedId, hostUnitId: lease.hostUnitId, childUnitId: lease.childUnitId, childType: lease.childType } : undefined;
|
||||
return lease ? { embedId: lease.embedId, hostUnitId: lease.hostUnitId, childUnitId: lease.childUnitId, childType: lease.childType, sessionMode: lease.sessionMode } : undefined;
|
||||
}
|
||||
|
||||
resolveActiveChildSessionRuntimeScope(): ISheetEmbedRuntimeDomScope | undefined {
|
||||
const lease = [...this._leases].find((item) => item.role === 'child-session');
|
||||
return lease ? { embedId: lease.embedId, hostUnitId: lease.hostUnitId, childUnitId: lease.childUnitId, childType: lease.childType } : undefined;
|
||||
return lease ? { embedId: lease.embedId, hostUnitId: lease.hostUnitId, childUnitId: lease.childUnitId, childType: lease.childType, sessionMode: lease.sessionMode } : undefined;
|
||||
}
|
||||
|
||||
isChildUnitInActiveSession(unitId: string | undefined): boolean {
|
||||
|
||||
Reference in New Issue
Block a user