Skip to content

Commit 4808dc6

Browse files
committed
feat(embed): coordinate runtime focus scopes
1 parent 3fcbe65 commit 4808dc6

60 files changed

Lines changed: 8288 additions & 358 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-drawing-ui/src/controllers/__tests__/doc-float-dom.controller.spec.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,31 @@ describe('DocFloatDomController', () => {
183183
controller.dispose();
184184
});
185185

186+
it('keeps embed custom block float doms visible when focus moves into child units', async () => {
187+
const rect = new Rect('dom-rect', {
188+
left: 30,
189+
top: 50,
190+
width: 50,
191+
height: 40,
192+
} as never);
193+
const { controller, add$, canvasFloatDomService } = createController({
194+
rects: [rect],
195+
drawing: {
196+
data: { version: 1, embedId: 'embed-1', hostAnchorId: 'anchor-1' },
197+
},
198+
});
199+
200+
add$.next([{ unitId: 'doc-1', subUnitId: 'doc-1', drawingId: 'dom-1' }]);
201+
await Promise.resolve();
202+
203+
expect(canvasFloatDomService.addFloatDom).toHaveBeenCalledWith(expect.objectContaining({
204+
eventPassThrough: false,
205+
preserveOnFocusChange: true,
206+
}));
207+
208+
controller.dispose();
209+
});
210+
186211
it('updates float dom position from its own host viewport scroll even when current doc focus changes', async () => {
187212
const rect = new Rect('dom-rect', {
188213
left: 30,

packages/docs-drawing-ui/src/controllers/doc-float-dom.controller.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,7 @@ export class DocFloatDomController extends Disposable {
261261
id: rectParam.drawingId,
262262
componentKey: rectParam.componentKey,
263263
eventPassThrough: preserveRuntimeGeometry ? false : undefined,
264+
preserveOnFocusChange: preserveRuntimeGeometry,
264265
onPointerDown: (evt) => {
265266
canvas.dispatchEvent(new PointerEvent(evt.type, evt));
266267
},

packages/docs-ui/src/EmbedDocsCustomBlockRenderer.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { VIEWPORT_KEY } from './basics/docs-view-key';
2626
import { SetDocZoomRatioOperation } from './commands/operations/set-doc-zoom-ratio.operation';
2727
import { createDefaultDocsTableLikeCustomBlockBleedViewport, resolveDocsTableLikeCustomBlockBleedViewport, resolveDocsTableLikeCustomBlockContentHeight, resolveDocsTableLikeCustomBlockContentWidth } from './embed-docs-custom-block-bleed';
2828
import { scrollDocsTableLikeCustomBlockLive } from './embed-docs-custom-block-scroll';
29+
import { DocSelectionRenderService } from './services/selection/doc-selection-render.service';
2930

3031
const SHEET_LIKE_CUSTOM_BLOCK_DEFAULT_CONTENT_HEIGHT = 480;
3132

@@ -208,6 +209,9 @@ export function EmbedDocsCustomBlockRenderer(props: { data?: IEmbedFloatDomData
208209
scene
209210
);
210211
}, [renderManagerService, resolvedHostUnitId]);
212+
const handleRuntimeStageEnter = useCallback((stage: 'inactive' | 'stage1' | 'stage2') => {
213+
blurHostDocSelectionWhenEmbedRuntimeEntersStage(renderManagerService, resolvedHostUnitId, stage);
214+
}, [renderManagerService, resolvedHostUnitId]);
211215

212216
useEffect(() => {
213217
const root = rootRef.current;
@@ -251,12 +255,27 @@ export function EmbedDocsCustomBlockRenderer(props: { data?: IEmbedFloatDomData
251255
{...props}
252256
interactionFlow="doc-block"
253257
onHostWheel={sheetLike ? handleHostWheel : undefined}
258+
onRuntimeStageEnter={handleRuntimeStageEnter}
254259
syncHostVerticalScroll={sheetLike}
255260
/>
256261
</div>
257262
);
258263
}
259264

265+
export function blurHostDocSelectionWhenEmbedRuntimeEntersStage(
266+
renderManagerService: IRenderManagerService,
267+
hostUnitId: string | undefined,
268+
stage: 'inactive' | 'stage1' | 'stage2'
269+
): void {
270+
if (stage !== 'stage2' || !hostUnitId) {
271+
return;
272+
}
273+
274+
renderManagerService.getRenderById(hostUnitId)
275+
?.with(DocSelectionRenderService)
276+
?.blur();
277+
}
278+
260279
export function createDocsTableLikeCustomBlockWheelHandler(options: IDocsTableLikeCustomBlockWheelHandlerOptions): (event: WheelEvent) => void {
261280
return (event: WheelEvent) => {
262281
const live = options.getLive();
Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
/**
2+
* Copyright 2023-present DreamNum Co., Ltd.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
/**
18+
* @vitest-environment jsdom
19+
*/
20+
21+
import { DOCS_NORMAL_EDITOR_UNIT_ID_KEY } from '@univerjs/core';
22+
import { EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE, EmbedInteractionBoundaryService, EmbedRuntimeFocusCoordinator } from '@univerjs/embed-ui';
23+
import { Subject } from 'rxjs';
24+
import { describe, expect, it, vi } from 'vitest';
25+
import { DocInputController } from '../doc-input.controller';
26+
27+
describe('DocInputController', () => {
28+
it('does not insert host document text while an embedded child runtime owns focus', async () => {
29+
const onInput$ = new Subject<unknown>();
30+
const executeCommand = vi.fn();
31+
const focusCoordinator = new EmbedRuntimeFocusCoordinator();
32+
const lease = focusCoordinator.acquireLease({
33+
embedId: 'embed-sheet',
34+
role: 'child-session',
35+
owner: 'stage2-runtime',
36+
});
37+
38+
new DocInputController(
39+
{
40+
unitId: 'host-doc',
41+
unit: {
42+
getSelfOrHeaderFooterModel: vi.fn(() => ({
43+
getBody: vi.fn(() => ({ dataStream: '\r\n' })),
44+
})),
45+
},
46+
} as never,
47+
{ onInput$ } as never,
48+
{ getSkeleton: vi.fn(() => ({})) } as never,
49+
{ executeCommand } as never,
50+
{
51+
getDefaultStyle: vi.fn(() => ({})),
52+
getStyleCache: vi.fn(() => ({})),
53+
} as never,
54+
undefined,
55+
focusCoordinator
56+
);
57+
58+
onInput$.next({
59+
event: { defaultPrevented: false, data: '=' },
60+
content: '=',
61+
activeRange: {
62+
segmentId: undefined,
63+
startOffset: 0,
64+
endOffset: 0,
65+
},
66+
});
67+
await Promise.resolve();
68+
69+
expect(executeCommand).not.toHaveBeenCalled();
70+
lease.dispose();
71+
});
72+
73+
it('keeps sheet cell editor input available while an embedded child runtime owns focus', async () => {
74+
const onInput$ = new Subject<unknown>();
75+
const executeCommand = vi.fn();
76+
const focusCoordinator = new EmbedRuntimeFocusCoordinator();
77+
const lease = focusCoordinator.acquireLease({
78+
embedId: 'embed-sheet',
79+
role: 'child-session',
80+
owner: 'stage2-runtime',
81+
});
82+
83+
new DocInputController(
84+
{
85+
unitId: DOCS_NORMAL_EDITOR_UNIT_ID_KEY,
86+
unit: {
87+
getSelfOrHeaderFooterModel: vi.fn(() => ({
88+
getBody: vi.fn(() => ({ dataStream: '\r\n' })),
89+
})),
90+
},
91+
} as never,
92+
{ onInput$ } as never,
93+
{ getSkeleton: vi.fn(() => ({})) } as never,
94+
{ executeCommand } as never,
95+
{
96+
getDefaultStyle: vi.fn(() => ({})),
97+
getStyleCache: vi.fn(() => ({})),
98+
} as never,
99+
undefined,
100+
focusCoordinator
101+
);
102+
103+
onInput$.next({
104+
event: { defaultPrevented: false, data: '=' },
105+
content: '=',
106+
activeRange: {
107+
segmentId: undefined,
108+
startOffset: 0,
109+
endOffset: 0,
110+
},
111+
});
112+
await Promise.resolve();
113+
114+
expect(executeCommand).toHaveBeenCalledWith('doc.command.insert-text', expect.objectContaining({
115+
unitId: DOCS_NORMAL_EDITOR_UNIT_ID_KEY,
116+
}));
117+
lease.dispose();
118+
});
119+
120+
it('does not let a host-scoped child session suppress unrelated host document input', async () => {
121+
const onInput$ = new Subject<unknown>();
122+
const executeCommand = vi.fn();
123+
const focusCoordinator = new EmbedRuntimeFocusCoordinator();
124+
const lease = focusCoordinator.acquireLease({
125+
embedId: 'embed-sheet',
126+
role: 'child-session',
127+
owner: 'stage2-runtime',
128+
hostUnitId: 'host-doc',
129+
childUnitId: 'child-sheet',
130+
});
131+
132+
new DocInputController(
133+
{
134+
unitId: 'other-host-doc',
135+
unit: {
136+
getSelfOrHeaderFooterModel: vi.fn(() => ({
137+
getBody: vi.fn(() => ({ dataStream: '\r\n' })),
138+
})),
139+
},
140+
} as never,
141+
{ onInput$ } as never,
142+
{ getSkeleton: vi.fn(() => ({})) } as never,
143+
{ executeCommand } as never,
144+
{
145+
getDefaultStyle: vi.fn(() => ({})),
146+
getStyleCache: vi.fn(() => ({})),
147+
} as never,
148+
undefined,
149+
focusCoordinator
150+
);
151+
152+
onInput$.next({
153+
event: { defaultPrevented: false, data: 'o' },
154+
content: 'o',
155+
activeRange: {
156+
segmentId: undefined,
157+
startOffset: 0,
158+
endOffset: 0,
159+
},
160+
});
161+
await Promise.resolve();
162+
163+
expect(executeCommand).toHaveBeenCalledWith('doc.command.insert-text', expect.objectContaining({
164+
unitId: 'other-host-doc',
165+
}));
166+
lease.dispose();
167+
});
168+
169+
it('keeps embedded child document input available while its host owns the embed session', async () => {
170+
const onInput$ = new Subject<unknown>();
171+
const executeCommand = vi.fn();
172+
const focusCoordinator = new EmbedRuntimeFocusCoordinator();
173+
const interactionBoundaryService = new EmbedInteractionBoundaryService();
174+
const childEditor = document.createElement('div');
175+
childEditor.setAttribute(EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE, 'embed-doc');
176+
const childInput = document.createElement('input');
177+
childEditor.appendChild(childInput);
178+
document.body.appendChild(childEditor);
179+
const lease = focusCoordinator.acquireLease({
180+
embedId: 'embed-doc',
181+
role: 'child-session',
182+
owner: 'stage2-runtime',
183+
hostUnitId: 'host-sheet',
184+
childUnitId: 'child-doc',
185+
});
186+
187+
new DocInputController(
188+
{
189+
unitId: 'child-doc',
190+
unit: {
191+
getSelfOrHeaderFooterModel: vi.fn(() => ({
192+
getBody: vi.fn(() => ({ dataStream: '\r\n' })),
193+
})),
194+
},
195+
} as never,
196+
{ onInput$ } as never,
197+
{ getSkeleton: vi.fn(() => ({})) } as never,
198+
{ executeCommand } as never,
199+
{
200+
getDefaultStyle: vi.fn(() => ({})),
201+
getStyleCache: vi.fn(() => ({})),
202+
} as never,
203+
interactionBoundaryService,
204+
focusCoordinator
205+
);
206+
207+
onInput$.next({
208+
event: { defaultPrevented: false, data: 'a', target: childInput },
209+
content: 'a',
210+
activeRange: {
211+
segmentId: undefined,
212+
startOffset: 0,
213+
endOffset: 0,
214+
},
215+
});
216+
await Promise.resolve();
217+
218+
expect(executeCommand).toHaveBeenCalledWith('doc.command.insert-text', expect.objectContaining({
219+
unitId: 'child-doc',
220+
}));
221+
lease.dispose();
222+
childEditor.remove();
223+
});
224+
225+
it('keeps tab embedded child document input available through runtime ownership without a stage2 lease', async () => {
226+
const onInput$ = new Subject<unknown>();
227+
const executeCommand = vi.fn();
228+
const focusCoordinator = new EmbedRuntimeFocusCoordinator();
229+
const interactionBoundaryService = new EmbedInteractionBoundaryService();
230+
const childEditor = document.createElement('div');
231+
childEditor.setAttribute(EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE, 'sheets-tab-doc');
232+
const childInput = document.createElement('input');
233+
childEditor.appendChild(childInput);
234+
document.body.appendChild(childEditor);
235+
const runtimeScope = focusCoordinator.registerRuntimeScope({
236+
embedId: 'sheets-tab-doc',
237+
hostUnitId: 'host-sheet',
238+
childUnitId: 'child-doc',
239+
});
240+
241+
new DocInputController(
242+
{
243+
unitId: 'child-doc',
244+
unit: {
245+
getSelfOrHeaderFooterModel: vi.fn(() => ({
246+
getBody: vi.fn(() => ({ dataStream: '\r\n' })),
247+
})),
248+
},
249+
} as never,
250+
{ onInput$ } as never,
251+
{ getSkeleton: vi.fn(() => ({})) } as never,
252+
{ executeCommand } as never,
253+
{
254+
getDefaultStyle: vi.fn(() => ({})),
255+
getStyleCache: vi.fn(() => ({})),
256+
} as never,
257+
interactionBoundaryService,
258+
focusCoordinator
259+
);
260+
261+
onInput$.next({
262+
event: { defaultPrevented: false, data: 'x', target: childInput },
263+
content: 'x',
264+
activeRange: {
265+
segmentId: undefined,
266+
startOffset: 0,
267+
endOffset: 0,
268+
},
269+
});
270+
await Promise.resolve();
271+
272+
expect(executeCommand).toHaveBeenCalledWith('doc.command.insert-text', expect.objectContaining({
273+
unitId: 'child-doc',
274+
}));
275+
runtimeScope.dispose();
276+
childEditor.remove();
277+
});
278+
});

0 commit comments

Comments
 (0)