fix: improve embedded editor interactions (#7376)

This commit is contained in:
wpxp123456
2026-07-30 15:10:53 +08:00
committed by GitHub
parent cc6211b8ed
commit 89981030bf
13 changed files with 190 additions and 9 deletions
@@ -58,7 +58,7 @@ function createEventSubject() {
};
}
function createController(options: { readonly?: boolean; hasEditor?: boolean; embedRecentInteraction?: boolean; embedContains?: boolean; unitId?: string; currentSelectionUnitId?: string; embedRuntimeFocusCoordinator?: EmbedRuntimeFocusCoordinator } = {}) {
function createController(options: { readonly?: boolean; hasEditor?: boolean; preserveHostFocus?: boolean; embedRecentInteraction?: boolean; embedContains?: boolean; unitId?: string; currentSelectionUnitId?: string; embedRuntimeFocusCoordinator?: EmbedRuntimeFocusCoordinator } = {}) {
const refreshSelection$ = new Subject<any>();
const textSelectionInner$ = new Subject<any>();
const currentSkeleton$ = new Subject<any>();
@@ -121,6 +121,7 @@ function createController(options: { readonly?: boolean; hasEditor?: boolean; em
: null;
const editorService = {
getEditor: vi.fn(() => editor),
getEditorRenderConfig: vi.fn(() => ({ preserveHostFocus: options.preserveHostFocus })),
focus: vi.fn(),
getFocusId: vi.fn(() => null),
};
@@ -412,6 +413,20 @@ describe('DocSelectionRenderController', () => {
controller.dispose();
});
it('preserves the host unit focus for configured editors', () => {
const { controller, document, editorService, instanceService } = createController({
hasEditor: true,
preserveHostFocus: true,
});
document.onPointerDown$.emit({ offsetX: 11, offsetY: 22, button: 0 }, { stopPropagation: vi.fn() });
expect(editorService.focus).toHaveBeenCalledWith('doc-1');
expect(instanceService.focusUnit).not.toHaveBeenCalled();
controller.dispose();
});
it('ignores pointer gestures that originate inside an embed interaction boundary', () => {
const {
controller,
@@ -148,7 +148,10 @@ export class DocSelectionRenderController extends Disposable implements IRenderM
if (docDataModel?.getUnitId() !== unitId) {
this._instanceSrv.setCurrentUnitForType(unitId);
}
this._instanceSrv.focusUnit(unitId);
// Host-owned editors need the current Doc unit for editing commands without replacing the host's global focus.
if (!this._editorService.getEditorRenderConfig(unitId)?.preserveHostFocus) {
this._instanceSrv.focusUnit(unitId);
}
const skeleton = this._docSkeletonManagerService.getSkeleton();
const { offsetX, offsetY } = evt;
@@ -510,6 +510,7 @@ describe('EditorService', () => {
canvasStyle: { backgroundColor: '#ffffff' },
scrollBar: false,
backScrollOffset: 12,
preserveHostFocus: true,
}, container);
const render = TestRegisterRenderManagerService.renders.get(editorUnitId)!;
@@ -519,6 +520,7 @@ describe('EditorService', () => {
canvasStyle: { backgroundColor: '#ffffff' },
scrollBar: false,
backScrollOffset: 12,
preserveHostFocus: true,
});
expect(render.container).toBe(container);
expect(render.viewport.disposed).toBe(true);
@@ -28,6 +28,7 @@ import { Editor } from './editor';
export interface IEditorRenderConfig {
canvasStyle: IEditorCanvasStyle;
preserveHostFocus?: boolean;
scrollBar?: boolean;
backScrollOffset?: number;
}
@@ -227,6 +228,7 @@ export class EditorService extends Disposable implements IEditorService, IDispos
canvasStyle,
scrollBar: config.scrollBar,
...(config.backScrollOffset === undefined ? {} : { backScrollOffset: config.backScrollOffset }),
...(config.preserveHostFocus === undefined ? {} : { preserveHostFocus: config.preserveHostFocus }),
});
const documentDataModel = this._univerInstanceService.getUnit<DocumentDataModel>(editorUnitId, UniverInstanceType.UNIVER_DOC);
@@ -103,6 +103,8 @@ export interface IEditorCanvasStyle {
export interface IEditorConfigParams {
initialSnapshot: IDocumentData;
/** Keep the globally focused unit on the editor's host while this editor receives input focus. */
preserveHostFocus?: boolean;
cancelDefaultResizeListener?: boolean;
canvasStyle?: IEditorCanvasStyle;
// A Boolean attribute which, if present, indicates that the editor should automatically have focus.
@@ -38,6 +38,8 @@ export interface IRichTextEditorProps {
onFocusChange?: (isFocus: boolean, newValue?: string) => void;
initialValue?: IDocumentData | string;
onClickOutside?: () => void;
/** Keep the globally focused unit on the editor's host while this editor receives input focus. */
preserveHostFocus?: boolean;
keyboardEventConfig?: IKeyboardEventConfig;
moveCursor?: boolean;
style?: CSSProperties;
@@ -60,6 +62,7 @@ export const RichTextEditor = (props: IRichTextEditorProps) => {
onFocusChange: _onFocusChange,
initialValue,
onClickOutside: _onClickOutside,
preserveHostFocus,
keyboardEventConfig,
moveCursor = true,
style,
@@ -86,6 +89,7 @@ export const RichTextEditor = (props: IRichTextEditorProps) => {
editorId,
initialValue,
container: formulaEditorContainerRef,
preserveHostFocus,
autoFocus,
isSingle,
});
@@ -54,4 +54,20 @@ describe('useEditor', () => {
expect(validateDocBodyStructure(snapshot.body!)).toEqual([]);
expect(snapshot.body?.paragraphs?.map((paragraph) => paragraph.startIndex)).toEqual([2]);
});
it('registers an editor that preserves its host focus', () => {
getEditor.mockReturnValue(editor);
useEditor({
editorId: 'range-editor',
initialValue: 'A1',
container: { current: { clientWidth: 320 } } as RefObject<HTMLDivElement>,
preserveHostFocus: true,
});
expect(register).toHaveBeenLastCalledWith(
expect.objectContaining({ preserveHostFocus: true }),
expect.anything()
);
});
});
@@ -26,13 +26,14 @@ export interface IUseEditorProps {
editorId: string;
initialValue: Nullable<IDocumentData | string>;
container: RefObject<HTMLDivElement>;
preserveHostFocus?: boolean;
autoFocus?: boolean;
isSingle?: boolean;
canvasStyle?: IEditorCanvasStyle;
}
export function useEditor(opts: IUseEditorProps) {
const { editorId, initialValue, container, autoFocus: _autoFocus, isSingle, canvasStyle } = opts;
const { editorId, initialValue, container, preserveHostFocus, autoFocus: _autoFocus, isSingle, canvasStyle } = opts;
const autoFocus = useMemo(() => _autoFocus ?? false, []);
const [editor, setEditor] = useState<Editor>();
const editorService = useDependency(IEditorService);
@@ -70,6 +71,7 @@ export function useEditor(opts: IUseEditorProps) {
canvasStyle,
editorUnitId: editorId,
initialSnapshot: snapshot,
preserveHostFocus,
},
container.current
);
@@ -343,6 +343,7 @@ export function RangeSelector(props: IRangeSelectorProps) {
<RichTextEditor
isSingle
{...props}
preserveHostFocus
onFocusChange={(focusing, newValue) => {
setFocusing(focusing);
onFocusChange?.(focusing, newValue);
@@ -25,12 +25,13 @@ import {
IContextService,
Injector,
IUniverInstanceService,
numberToABC,
ThemeService,
toDisposable,
UniverInstanceType,
} from '@univerjs/core';
import { DocSelectionRenderService, IEditorService } from '@univerjs/docs-ui';
import { DeviceInputEventType } from '@univerjs/engine-render';
import { DeviceInputEventType, IRenderManagerService } from '@univerjs/engine-render';
import {
ComponentManager,
DISABLE_AUTO_FOCUS_KEY,
@@ -41,7 +42,7 @@ import {
useSidebarClick,
} from '@univerjs/ui';
import { useEffect, useRef, useState } from 'react';
import { map, startWith } from 'rxjs';
import { map, skip, startWith } from 'rxjs';
import {
SetCellEditVisibleArrowOperation,
SetCellEditVisibleOperation,
@@ -50,6 +51,8 @@ import { EMBEDDING_FORMULA_EDITOR_COMPONENT_KEY } from '../../common/keys';
import { IEditorBridgeService } from '../../services/editor-bridge.service';
import { ICellEditorManagerService } from '../../services/editor/cell-editor-manager.service';
import { SheetCellEditorResizeService } from '../../services/editor/cell-editor-resize.service';
import { SheetScrollManagerService } from '../../services/scroll-manager.service';
import { genNormalSelectionStyle } from '../../services/selection/const';
import {
ISheetEmbedFloatingGeometryService,
ISheetEmbedInteractionBoundaryService,
@@ -213,6 +216,7 @@ export function EditorContainer() {
const componentManager = useDependency(ComponentManager);
const editorBridgeService = useDependency(IEditorBridgeService);
const cellEditorResizeService = useDependency(SheetCellEditorResizeService);
const renderManagerService = useDependency(IRenderManagerService);
const rootRef = useRef<HTMLDivElement>(null);
const pointerRefocusTimerRef = useRef<number | undefined>(undefined);
const visible = useObservable(editorBridgeService.visible$);
@@ -226,6 +230,10 @@ export function EditorContainer() {
const FormulaEditor = componentManager.get(EMBEDDING_FORMULA_EDITOR_COMPONENT_KEY);
const editState = useObservable(editorBridgeService.currentEditCellState$);
const darkMode = useObservable(themeService.darkMode$, themeService.darkMode);
const [showEditCellAddress, setShowEditCellAddress] = useState(false);
const editCellAddress = editState ? `${numberToABC(editState.column)}${editState.row + 1}` : null;
// The editor border uses the normal selection stroke, so the address label stays visually in sync with it.
const editorBorderColor = genNormalSelectionStyle(themeService).stroke;
const focusCoordinator = injector.has(ISheetEmbedRuntimeFocusCoordinator)
? injector.get(ISheetEmbedRuntimeFocusCoordinator)
: undefined;
@@ -247,6 +255,36 @@ export function EditorContainer() {
[editState?.unitId, focusCoordinator, instanceService, visible?.unitId]
);
useEffect(() => {
setShowEditCellAddress(false);
if (!visible?.visible || !editState?.unitId) {
return undefined;
}
const scrollManagerService = renderManagerService
.getRenderUnitById(editState.unitId)
?.with(SheetScrollManagerService);
if (!scrollManagerService) {
return undefined;
}
// The behavior subject replays the existing position; only later emissions belong to this edit session.
const subscription = scrollManagerService.validViewportScrollInfo$.pipe(skip(1)).subscribe((scrollInfo) => {
if (scrollInfo) {
setShowEditCellAddress(true);
}
});
return () => subscription.unsubscribe();
}, [
editState?.column,
editState?.row,
editState?.sheetId,
editState?.unitId,
renderManagerService,
visible?.visible,
]);
useEffect(() => {
const subscription = cellEditorManagerService.state$.subscribe((param) => {
if (param == null) {
@@ -649,6 +687,22 @@ export function EditorContainer() {
}),
}}
>
{visible?.visible && showEditCellAddress && editCellAddress && (
<div
data-u-comp="sheet-cell-editor-address"
className="
univer-pointer-events-none univer-absolute univer-left-0 univer-top-0 univer-z-10
univer-whitespace-nowrap univer-rounded-t univer-px-1.5 univer-text-xs univer-leading-5
"
style={{
backgroundColor: editorBorderColor,
color: themeService.getColorFromTheme('white'),
transform: 'translateY(-100%)',
}}
>
{editCellAddress}
</div>
)}
{FormulaEditor && (
<FormulaEditor
editorId={DOCS_NORMAL_EDITOR_UNIT_ID_KEY}
@@ -36,7 +36,7 @@ import {
UniverInstanceType,
} from '@univerjs/core';
import { IEditorService } from '@univerjs/docs-ui';
import { DeviceInputEventType } from '@univerjs/engine-render';
import { DeviceInputEventType, IRenderManagerService } from '@univerjs/engine-render';
import { ComponentManager, connectInjector, ILayoutService, ISidebarService } from '@univerjs/ui';
import { act } from 'react';
import { createRoot } from 'react-dom/client';
@@ -46,6 +46,7 @@ import { EMBEDDING_FORMULA_EDITOR_COMPONENT_KEY } from '../../../common/keys';
import { IEditorBridgeService } from '../../../services/editor-bridge.service';
import { ICellEditorManagerService } from '../../../services/editor/cell-editor-manager.service';
import { SheetCellEditorResizeService } from '../../../services/editor/cell-editor-resize.service';
import { SheetScrollManagerService } from '../../../services/scroll-manager.service';
import {
EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE,
EMBED_RUNTIME_FOCUS_ROLE_ATTRIBUTE,
@@ -145,6 +146,7 @@ function createTestBed(options: { docSelectionIsFocusing?: boolean; focusedUnitI
resizeCellEditor: vi.fn(),
fitTextSize: vi.fn(),
};
const validViewportScrollInfo$ = new BehaviorSubject<Nullable<Record<string, never>>>({});
const docSelectionRenderService = {
isFocusing: options.docSelectionIsFocusing ?? true,
focus: vi.fn(),
@@ -195,16 +197,25 @@ function createTestBed(options: { docSelectionIsFocusing?: boolean; focusedUnitI
useValue: {
darkMode: false,
darkMode$: of(false),
getColorFromTheme: () => '#fff',
getColorFromTheme: (color: string) => color === 'primary.600' ? '#00aa66' : '#fff',
} as never,
}]);
injector.add([SheetCellEditorResizeService, { useValue: cellEditorResizeService as never }]);
injector.add([IRenderManagerService, {
useValue: {
getRenderUnitById: () => ({
with: (token: unknown) => token === SheetScrollManagerService
? { validViewportScrollInfo$ }
: null,
}),
} as never,
}]);
injector.add([ILayoutService, { useValue: { focus: vi.fn() } as never }]);
injector.add([ISidebarService, { useValue: { getContainer: () => null } as never }]);
injector.add([ISheetEmbedRuntimeFocusCoordinator, { useValue: focusCoordinator }]);
injector.add([ISheetEmbedInteractionBoundaryService, { useValue: interactionBoundaryService }]);
return { injector, editorBridgeService, focusCoordinator, interactionBoundaryService, docSelectionRenderService, cellEditorResizeService };
return { injector, editorBridgeService, focusCoordinator, interactionBoundaryService, docSelectionRenderService, cellEditorResizeService, validViewportScrollInfo$ };
}
function renderEditorContainer(root: Root, injector: Injector): void {
@@ -560,6 +571,53 @@ describe('EditorContainer embed focus lease', () => {
expect(cellEditorResizeService.fitTextSize).toHaveBeenCalledTimes(1);
});
it('shows the edited cell address after scrolling and resets it for the next edit session', async () => {
const { injector, editorBridgeService, validViewportScrollInfo$ } = createTestBed();
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
renderEditorContainer(root!, injector);
await Promise.resolve();
});
expect(container.querySelector('[data-u-comp="sheet-cell-editor-address"]')).toBeNull();
await act(async () => {
validViewportScrollInfo$.next({});
await Promise.resolve();
});
const address = container.querySelector('[data-u-comp="sheet-cell-editor-address"]') as HTMLElement;
expect(address.textContent).toBe('A1');
expect(address.style.backgroundColor).toBe('rgb(0, 170, 102)');
expect(address.style.color).toBe('rgb(255, 255, 255)');
await act(async () => {
editorBridgeService.changeVisible({
visible: false,
eventType: DeviceInputEventType.PointerUp,
unitId: 'sheet-1',
});
await Promise.resolve();
});
expect(container.querySelector('[data-u-comp="sheet-cell-editor-address"]')).toBeNull();
await act(async () => {
editorBridgeService.changeVisible({
visible: true,
eventType: DeviceInputEventType.Dblclick,
unitId: 'sheet-1',
});
await Promise.resolve();
});
expect(container.querySelector('[data-u-comp="sheet-cell-editor-address"]')).toBeNull();
});
it('keeps the cell editor visible while formula range selection moves focus to the sheet canvas', async () => {
const { injector, editorBridgeService } = createTestBed();
container = document.createElement('div');
@@ -135,10 +135,12 @@ export const ThreadCommentEditor = forwardRef<IThreadCommentEditorInstance, IThr
return (
<div onClick={(e) => e.preventDefault()}>
<div onMouseDown={handleEditorMouseDown}>
{/* Comments are hosted by Sheets/Docs, so editing should not replace the host's global focus. */}
<RichTextEditor
className="univer-w-full"
editorRef={editor}
editorId={editorId}
preserveHostFocus
autoFocus={autoFocus}
keyboardEventConfig={keyboardEventConfig}
placeholder={localeService.t<LocaleKey>('thread-comment-ui.editor.placeholder')}
@@ -68,6 +68,7 @@ interface IEditorRecord {
data: IDocumentData;
selections: ISuccinctDocRangeParam[];
focused: boolean;
preserveHostFocus?: boolean;
order: string[];
}
@@ -204,13 +205,14 @@ class TestEditorService {
return id ? this._editors.get(id) : undefined;
}
register(config: { initialSnapshot: IDocumentData }, _container: HTMLDivElement): IDisposable {
register(config: { initialSnapshot: IDocumentData; preserveHostFocus?: boolean }, _container: HTMLDivElement): IDisposable {
const snapshot = config.initialSnapshot;
const record: IEditorRecord = {
id: snapshot.id,
data: snapshot,
selections: [],
focused: false,
preserveHostFocus: config.preserveHostFocus,
order: [],
};
const editor = new TestEditor(record);
@@ -452,6 +454,24 @@ describe('ThreadCommentEditor', () => {
expect(TestState.submitCount).toBe(0);
});
it('preserves the sheet host focus while editing a comment', () => {
const testBed = createEditorTestBed();
const rendered = renderEditor(
testBed.injector,
<ThreadCommentEditor
autoFocus={false}
editorId={EDITOR_ID}
subUnitId="subUnit"
type={UniverInstanceType.UNIVER_SHEET}
unitId="unit"
/>
);
root = rendered.root;
container = rendered.container;
expect(TestState.records.get(EDITOR_ID)?.preserveHostFocus).toBe(true);
});
it('saves reply content, clears the editor, and does not submit an outer form', () => {
const testBed = createEditorTestBed();
const editorRef = createRef<{ reply: (text: IDocumentBody) => void }>();