fix(embed): stabilize runtime UI ownership (#7532)

This commit is contained in:
Univer
2026-08-17 09:04:11 +08:00
committed by GitHub
parent bbad43f704
commit dc06ed8892
18 changed files with 570 additions and 108 deletions
@@ -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',
@@ -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;
@@ -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 };
}
@@ -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();
}
}
+1
View File
@@ -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';
@@ -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);
});
});
@@ -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({});
@@ -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(
@@ -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 {