diff --git a/packages/docs-ui/src/controllers/render-controllers/__tests__/doc-selection-render.controller.spec.ts b/packages/docs-ui/src/controllers/render-controllers/__tests__/doc-selection-render.controller.spec.ts index 101908ec1a..a2257aee8a 100644 --- a/packages/docs-ui/src/controllers/render-controllers/__tests__/doc-selection-render.controller.spec.ts +++ b/packages/docs-ui/src/controllers/render-controllers/__tests__/doc-selection-render.controller.spec.ts @@ -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(); const textSelectionInner$ = new Subject(); const currentSkeleton$ = new Subject(); @@ -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, diff --git a/packages/docs-ui/src/controllers/render-controllers/doc-selection-render.controller.ts b/packages/docs-ui/src/controllers/render-controllers/doc-selection-render.controller.ts index 040d10d312..d1302924e2 100644 --- a/packages/docs-ui/src/controllers/render-controllers/doc-selection-render.controller.ts +++ b/packages/docs-ui/src/controllers/render-controllers/doc-selection-render.controller.ts @@ -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; diff --git a/packages/docs-ui/src/services/editor/__tests__/editor-manager.service.spec.ts b/packages/docs-ui/src/services/editor/__tests__/editor-manager.service.spec.ts index 120bdab163..fe52e127ff 100644 --- a/packages/docs-ui/src/services/editor/__tests__/editor-manager.service.spec.ts +++ b/packages/docs-ui/src/services/editor/__tests__/editor-manager.service.spec.ts @@ -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); diff --git a/packages/docs-ui/src/services/editor/editor-manager.service.ts b/packages/docs-ui/src/services/editor/editor-manager.service.ts index 0ef6f7236d..9839016e0d 100644 --- a/packages/docs-ui/src/services/editor/editor-manager.service.ts +++ b/packages/docs-ui/src/services/editor/editor-manager.service.ts @@ -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(editorUnitId, UniverInstanceType.UNIVER_DOC); diff --git a/packages/docs-ui/src/services/editor/editor.ts b/packages/docs-ui/src/services/editor/editor.ts index e3c0010e9a..95bf966f7d 100644 --- a/packages/docs-ui/src/services/editor/editor.ts +++ b/packages/docs-ui/src/services/editor/editor.ts @@ -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. diff --git a/packages/docs-ui/src/views/RichTextEditor.tsx b/packages/docs-ui/src/views/RichTextEditor.tsx index 58e1a0740c..67dca4c06a 100644 --- a/packages/docs-ui/src/views/RichTextEditor.tsx +++ b/packages/docs-ui/src/views/RichTextEditor.tsx @@ -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, }); diff --git a/packages/docs-ui/src/views/rich-text-editor/hooks/__tests__/use-editor.spec.ts b/packages/docs-ui/src/views/rich-text-editor/hooks/__tests__/use-editor.spec.ts index d3c212e70a..c316e384f2 100644 --- a/packages/docs-ui/src/views/rich-text-editor/hooks/__tests__/use-editor.spec.ts +++ b/packages/docs-ui/src/views/rich-text-editor/hooks/__tests__/use-editor.spec.ts @@ -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, + preserveHostFocus: true, + }); + + expect(register).toHaveBeenLastCalledWith( + expect.objectContaining({ preserveHostFocus: true }), + expect.anything() + ); + }); }); diff --git a/packages/docs-ui/src/views/rich-text-editor/hooks/use-editor.ts b/packages/docs-ui/src/views/rich-text-editor/hooks/use-editor.ts index a3b2a037c2..897998a06d 100644 --- a/packages/docs-ui/src/views/rich-text-editor/hooks/use-editor.ts +++ b/packages/docs-ui/src/views/rich-text-editor/hooks/use-editor.ts @@ -26,13 +26,14 @@ export interface IUseEditorProps { editorId: string; initialValue: Nullable; container: RefObject; + 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(); const editorService = useDependency(IEditorService); @@ -70,6 +71,7 @@ export function useEditor(opts: IUseEditorProps) { canvasStyle, editorUnitId: editorId, initialSnapshot: snapshot, + preserveHostFocus, }, container.current ); diff --git a/packages/sheets-formula-ui/src/views/range-selector/index.tsx b/packages/sheets-formula-ui/src/views/range-selector/index.tsx index cefb1f6dc1..750f33b7ec 100644 --- a/packages/sheets-formula-ui/src/views/range-selector/index.tsx +++ b/packages/sheets-formula-ui/src/views/range-selector/index.tsx @@ -343,6 +343,7 @@ export function RangeSelector(props: IRangeSelectorProps) { { setFocusing(focusing); onFocusChange?.(focusing, newValue); diff --git a/packages/sheets-ui/src/views/editor-container/EditorContainer.tsx b/packages/sheets-ui/src/views/editor-container/EditorContainer.tsx index ba458b6ab9..cb03144472 100644 --- a/packages/sheets-ui/src/views/editor-container/EditorContainer.tsx +++ b/packages/sheets-ui/src/views/editor-container/EditorContainer.tsx @@ -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(null); const pointerRefocusTimerRef = useRef(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 && ( +
+ {editCellAddress} +
+ )} {FormulaEditor && ( >>({}); 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'); diff --git a/packages/thread-comment-ui/src/views/ThreadCommentEditor.tsx b/packages/thread-comment-ui/src/views/ThreadCommentEditor.tsx index 9e2cd3f1b3..e21068cc1e 100644 --- a/packages/thread-comment-ui/src/views/ThreadCommentEditor.tsx +++ b/packages/thread-comment-ui/src/views/ThreadCommentEditor.tsx @@ -135,10 +135,12 @@ export const ThreadCommentEditor = forwardRef e.preventDefault()}>
+ {/* Comments are hosted by Sheets/Docs, so editing should not replace the host's global focus. */} ('thread-comment-ui.editor.placeholder')} diff --git a/packages/thread-comment-ui/src/views/__tests__/ThreadCommentEditor.spec.tsx b/packages/thread-comment-ui/src/views/__tests__/ThreadCommentEditor.spec.tsx index fdb74894d7..1437237430 100644 --- a/packages/thread-comment-ui/src/views/__tests__/ThreadCommentEditor.spec.tsx +++ b/packages/thread-comment-ui/src/views/__tests__/ThreadCommentEditor.spec.tsx @@ -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, + + ); + 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 }>();