Skip to content

Commit a7fa4a2

Browse files
committed
fix: stabilize embed editor focus
1 parent e715bbe commit a7fa4a2

46 files changed

Lines changed: 1396 additions & 311 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/docs-ui/src/controllers/render-controllers/embed-docs-custom-block-bleed.render-controller.spec.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,13 +88,17 @@ describe('EmbedDocsCustomBlockBleedRenderController', () => {
8888
commandListeners[0]({ id: SetDocZoomRatioOperation.id, params: { unitId: 'sheet-1' } });
8989
expect(commandService.syncExecuteCommand).not.toHaveBeenCalled();
9090

91-
commandListeners[0]({ id: 'sheet-command', params: { unitId: 'sheet-1' } });
91+
commandListeners[0]({ id: 'sheet.mutation.set-range-values', params: { unitId: 'sheet-1' } });
9292
vi.runOnlyPendingTimers();
9393
expect(commandService.syncExecuteCommand).toHaveBeenCalledWith(SetDocZoomRatioOperation.id, {
9494
unitId: 'doc-1',
9595
zoomRatio: 1.5,
9696
});
9797

98+
commandListeners[0]({ id: 'sheet.operation.set-selections', params: { unitId: 'sheet-1' } });
99+
vi.runOnlyPendingTimers();
100+
expect(commandService.syncExecuteCommand).toHaveBeenCalledTimes(1);
101+
98102
commandListeners[0]({ id: 'host-command', params: { unitId: 'doc-1' } });
99103
expect(commandService.syncExecuteCommand).toHaveBeenCalledTimes(1);
100104

@@ -162,7 +166,7 @@ describe('EmbedDocsCustomBlockBleedRenderController', () => {
162166
viewportWidth: 960,
163167
});
164168

165-
commandListeners[0]({ id: 'sheet-command', params: { unitId: 'sheet-1' } });
169+
commandListeners[0]({ id: 'sheet.mutation.set-range-values', params: { unitId: 'sheet-1' } });
166170
vi.runOnlyPendingTimers();
167171
expect(commandService.syncExecuteCommand).toHaveBeenCalledWith(SetDocZoomRatioOperation.id, {
168172
unitId: 'doc-1',

packages/docs-ui/src/controllers/render-controllers/embed-docs-custom-block-bleed.render-controller.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ export class EmbedDocsCustomBlockBleedRenderController extends Disposable implem
129129
);
130130
if (!shouldRefreshDocsCustomBlockSizeForCommand({
131131
childUnitIds,
132+
commandId: command.id,
132133
commandParams: command.params,
133134
hostUnitId: this._context.unitId,
134135
})) {

packages/docs-ui/src/embed-block.spec.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@
1515
*/
1616

1717
import { IConfigService, UniverInstanceType } from '@univerjs/core';
18-
import { describe, expect, it, vi } from 'vitest';
18+
import { mountEmbedRenderChildUnit } from '@univerjs/embed-ui';
19+
import { beforeEach, describe, expect, it, vi } from 'vitest';
1920
import { DOCS_UI_PLUGIN_CONFIG_KEY } from './config/config';
2021
import { createDocsEmbedBlockContribution, createDocsEmbedChildViewContribution } from './embed-block';
2122

@@ -28,14 +29,18 @@ vi.mock('@univerjs/embed-ui', async (importOriginal) => {
2829
});
2930

3031
describe('docs embed block contribution', () => {
32+
beforeEach(() => {
33+
vi.mocked(mountEmbedRenderChildUnit).mockClear();
34+
});
35+
3136
it('uses a ribbon block contribution for docs child units', () => {
3237
expect(createDocsEmbedBlockContribution()).toMatchObject({
3338
childType: UniverInstanceType.UNIVER_DOC,
3439
productName: 'Docs',
3540
});
3641
});
3742

38-
it('injects fit-to-width config only for floating docs blocks', () => {
43+
it('passes fit-to-width config through a scoped render injector for floating docs blocks', () => {
3944
const childView = createDocsEmbedChildViewContribution();
4045
const configService = {
4146
getConfig: vi.fn((id: string | symbol) => id === DOCS_UI_PLUGIN_CONFIG_KEY
@@ -49,7 +54,13 @@ describe('docs embed block contribution', () => {
4954
runtimeScope: { injector },
5055
} as never);
5156

52-
const replacement = injector.add.mock.calls[0][0][1].useValue;
57+
const mountOptions = vi.mocked(mountEmbedRenderChildUnit).mock.calls[0][3];
58+
const scopedInjector = mountOptions?.scopedInjector;
59+
expect(scopedInjector).toBeDefined();
60+
expect(scopedInjector).not.toBe(injector);
61+
expect(injector.add).not.toHaveBeenCalled();
62+
63+
const replacement = scopedInjector!.get(IConfigService);
5364
expect(replacement.getConfig(DOCS_UI_PLUGIN_CONFIG_KEY)).toMatchObject({
5465
fitToWidth: {
5566
align: 'start',
@@ -67,6 +78,7 @@ describe('docs embed block contribution', () => {
6778
runtimeScope: { injector: tabInjector },
6879
} as never);
6980
expect(tabInjector.add).not.toHaveBeenCalled();
81+
expect(vi.mocked(mountEmbedRenderChildUnit).mock.calls[1][3]?.scopedInjector).toBeUndefined();
7082
});
7183

7284
it('skips config injection when config service is unavailable', () => {

packages/docs-ui/src/embed-block.ts

Lines changed: 11 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,11 @@
1414
* limitations under the License.
1515
*/
1616

17-
import type { IConfigService as IConfigServiceType, Injector } from '@univerjs/core';
17+
import type { Injector } from '@univerjs/core';
1818
import type { IEmbedBlockContribution, IEmbedChildContainerContext, IEmbedChildViewContribution } from '@univerjs/embed-ui';
1919
import type { IDocFitToWidthOptions, IUniverDocsUIConfig } from './config/config';
20-
import { IConfigService, UniverInstanceType } from '@univerjs/core';
21-
import { createEmbedRibbonBlockContribution, mountEmbedRenderChildUnit } from '@univerjs/embed-ui';
20+
import { UniverInstanceType } from '@univerjs/core';
21+
import { createEmbedRibbonBlockContribution, createEmbedScopedConfigInjector, mountEmbedRenderChildUnit } from '@univerjs/embed-ui';
2222
import { IRenderManagerService } from '@univerjs/engine-render';
2323
import { DOCS_UI_PLUGIN_CONFIG_KEY } from './config/config';
2424
import { DocFloatMenuService } from './services/float-menu.service';
@@ -46,8 +46,11 @@ export function createDocsEmbedChildViewContribution(): IEmbedChildViewContribut
4646
beforeDeactivate: deactivateEmbeddedDocSelection,
4747
mount: (context) => {
4848
if (context.renderScope.mode === 'float') {
49-
applyDocsEmbedFitToWidthConfig(context.runtimeScope.injector);
49+
return mountEmbedRenderChildUnit(context, IRenderManagerService, undefined, {
50+
scopedInjector: createDocsEmbedRenderScopedInjector(context.runtimeScope.injector),
51+
});
5052
}
53+
5154
return mountEmbedRenderChildUnit(context, IRenderManagerService);
5255
},
5356
};
@@ -63,34 +66,10 @@ function deactivateEmbeddedDocSelection(context: IEmbedChildContainerContext): v
6366
docSelectionRenderService?.blur();
6467
}
6568

66-
function applyDocsEmbedFitToWidthConfig(injector: Injector): void {
67-
if (!injector.has(IConfigService)) {
68-
return;
69-
}
70-
71-
const configService = injector.get<IConfigServiceType>(IConfigService);
72-
injector.add([IConfigService, {
73-
useValue: createDocsEmbedConfigService(configService),
74-
}]);
75-
}
76-
77-
function createDocsEmbedConfigService(configService: IConfigServiceType): IConfigServiceType {
78-
return new Proxy(configService, {
79-
get(target, property, receiver) {
80-
if (property === 'getConfig') {
81-
return <T>(id: string | symbol): T => {
82-
const config = target.getConfig<T>(id);
83-
if (id !== DOCS_UI_PLUGIN_CONFIG_KEY) {
84-
return config;
85-
}
86-
87-
return withDocsEmbedFitToWidthConfig(config as Partial<IUniverDocsUIConfig> | undefined) as T;
88-
};
89-
}
90-
91-
return Reflect.get(target, property, receiver);
92-
},
93-
});
69+
function createDocsEmbedRenderScopedInjector(injector: Injector): Injector | undefined {
70+
return createEmbedScopedConfigInjector(injector, new Map([
71+
[DOCS_UI_PLUGIN_CONFIG_KEY, (config) => withDocsEmbedFitToWidthConfig(config as Partial<IUniverDocsUIConfig> | undefined)],
72+
]));
9473
}
9574

9675
function withDocsEmbedFitToWidthConfig(config: Partial<IUniverDocsUIConfig> | undefined): Partial<IUniverDocsUIConfig> {

packages/docs-ui/src/embed-docs-custom-block-refresh.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,14 +62,38 @@ export function getCommandUnitId(commandParams: unknown): string | undefined {
6262
}
6363

6464
export function shouldRefreshDocsCustomBlockSizeForCommand(params: {
65+
commandId?: string;
6566
childUnitIds: Set<string>;
6667
commandParams: unknown;
6768
hostUnitId: string;
6869
}): boolean {
70+
if (params.commandId && isDocsCustomBlockLayoutNeutralCommand(params.commandId)) {
71+
return false;
72+
}
73+
6974
const commandUnitId = getCommandUnitId(params.commandParams);
7075
return Boolean(commandUnitId && commandUnitId !== params.hostUnitId && params.childUnitIds.has(commandUnitId));
7176
}
7277

78+
const DOCS_CUSTOM_BLOCK_LAYOUT_NEUTRAL_COMMAND_IDS = new Set([
79+
'sheet.command.expand-selection',
80+
'sheet.command.move-selection',
81+
'sheet.operation.scroll-to-cell',
82+
'sheet.operation.scroll-to-range',
83+
'sheet.operation.set-activate-cell-edit',
84+
'sheet.operation.set-cell-edit-visible',
85+
'sheet.operation.set-cell-edit-visible-arrow',
86+
'sheet.operation.set-cell-edit-visible-f2',
87+
'sheet.operation.set-format-painter',
88+
'sheet.operation.set-scroll',
89+
'sheet.operation.set-selections',
90+
'sheet.operation.set-zoom-ratio',
91+
]);
92+
93+
function isDocsCustomBlockLayoutNeutralCommand(commandId: string): boolean {
94+
return DOCS_CUSTOM_BLOCK_LAYOUT_NEUTRAL_COMMAND_IDS.has(commandId);
95+
}
96+
7397
export interface IDocsCustomBlockSizeRefreshScheduler {
7498
dispose: () => void;
7599
schedule: () => void;

packages/docs-ui/src/embed-register.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ describe('registerDocsEmbedUIContributions', () => {
5555

5656
registerDocsEmbedUIContributions(injector as never);
5757
expect(injector.adapterRegistry.list()).toHaveLength(1);
58-
expect(injector.previewService.registerProvider).toHaveBeenCalledTimes(2);
58+
expect(injector.previewService.registerProvider).toHaveBeenCalledTimes(1);
5959
});
6060

6161
it('registers immediately when embed-ui registries are already present', () => {

packages/docs-ui/src/menu/menu.spec.ts

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,11 @@
1515
*/
1616

1717
import type { IAccessor } from '@univerjs/core';
18+
import { ICommandService, IUniverInstanceService } from '@univerjs/core';
19+
import { DocSelectionManagerService } from '@univerjs/docs';
20+
import { EmbedRuntimeFocusCoordinator } from '@univerjs/embed-ui';
1821
import { describe, expect, it, vi } from 'vitest';
19-
import { getParagraphStyleAtCursor } from './menu';
22+
import { AlignMenuItemFactory, getParagraphStyleAtCursor, shouldSuppressDocMenuStateRefresh } from './menu';
2023

2124
describe('docs-ui menu helpers', () => {
2225
it('ignores stale menu calculations after an embedded doc injector is disposed', () => {
@@ -30,4 +33,70 @@ describe('docs-ui menu helpers', () => {
3033

3134
expect(getParagraphStyleAtCursor(accessor)).toBeUndefined();
3235
});
36+
37+
it('suppresses host doc menu state refresh while an embedded child owns focus', () => {
38+
const getDocRanges = vi.fn(() => [{ startOffset: 0, endOffset: 0, isActive: true }]);
39+
const accessor = {
40+
get: vi.fn((token) => {
41+
if (token === IUniverInstanceService) {
42+
return {
43+
getCurrentUnitOfType: () => ({ getUnitId: () => 'host-doc' }),
44+
};
45+
}
46+
if (token === EmbedRuntimeFocusCoordinator) {
47+
return {
48+
shouldSuppressHostInteraction: (unitId: string | undefined) => unitId === 'host-doc',
49+
};
50+
}
51+
if (token === DocSelectionManagerService) {
52+
return { getDocRanges };
53+
}
54+
throw new Error('Unexpected dependency');
55+
}),
56+
} as unknown as IAccessor;
57+
58+
expect(shouldSuppressDocMenuStateRefresh(accessor)).toBe(true);
59+
expect(getParagraphStyleAtCursor(accessor)).toBeUndefined();
60+
expect(getDocRanges).not.toHaveBeenCalled();
61+
});
62+
63+
it('keeps selector icons synchronous while suppressing host doc menu refresh', () => {
64+
const accessor = {
65+
get: vi.fn((token) => {
66+
if (token === ICommandService) {
67+
return {
68+
onCommandExecuted: vi.fn(() => ({ dispose: vi.fn() })),
69+
};
70+
}
71+
if (token === IUniverInstanceService) {
72+
return {
73+
getCurrentUnitOfType: () => ({ getUnitId: () => 'host-doc' }),
74+
};
75+
}
76+
if (token === EmbedRuntimeFocusCoordinator) {
77+
return {
78+
shouldSuppressHostInteraction: (unitId: string | undefined) => unitId === 'host-doc',
79+
};
80+
}
81+
if (token === DocSelectionManagerService) {
82+
return {
83+
textSelection$: {
84+
subscribe: () => ({ unsubscribe: vi.fn() }),
85+
},
86+
};
87+
}
88+
throw new Error('Unexpected dependency');
89+
}),
90+
} as unknown as IAccessor;
91+
92+
const item = AlignMenuItemFactory(accessor);
93+
let icon: unknown;
94+
const subscription = (item.icon as { subscribe: (listener: (value: unknown) => void) => { unsubscribe: () => void } })
95+
.subscribe((value) => {
96+
icon = value;
97+
});
98+
99+
expect(icon).toBe('LeftJustifyingIcon');
100+
subscription.unsubscribe();
101+
});
33102
});

0 commit comments

Comments
 (0)