mirror of
https://github.com/dream-num/univer.git
synced 2026-10-02 04:24:34 +08:00
fix: improve embedded editor interactions (#7376)
This commit is contained in:
+16
-1
@@ -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,
|
||||
|
||||
+4
-1
@@ -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 }>();
|
||||
|
||||
Reference in New Issue
Block a user