From 1fb69c0c7caea4c109073ff77edc42af5d67d40d Mon Sep 17 00:00:00 2001 From: Univer <68851825+DR-Univer@users.noreply.github.com> Date: Mon, 20 Jul 2026 18:16:26 +0800 Subject: [PATCH] feat(sheets-ui): improve large sheet loading preview (#7297) --- .../sheets-ui/src/__tests__/plugin.spec.ts | 51 +++++ packages/sheets-ui/src/plugin.ts | 6 +- .../sheet-loading-render.service.spec.ts | 210 ++++++++++++++++-- .../services/sheet-loading-render.service.ts | 204 +++++++++++++++-- packages/sheets-ui/src/views/hook.ts | 7 +- .../views/sheet-container/SheetContainer.tsx | 72 +++++- 6 files changed, 507 insertions(+), 43 deletions(-) create mode 100644 packages/sheets-ui/src/__tests__/plugin.spec.ts diff --git a/packages/sheets-ui/src/__tests__/plugin.spec.ts b/packages/sheets-ui/src/__tests__/plugin.spec.ts new file mode 100644 index 0000000000..fc9479acd7 --- /dev/null +++ b/packages/sheets-ui/src/__tests__/plugin.spec.ts @@ -0,0 +1,51 @@ +/** + * Copyright 2023-present DreamNum Co., Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { IConfigService, IUniverInstanceService, Univer, UniverInstanceType } from '@univerjs/core'; +import { IRenderManagerService, RenderManagerService } from '@univerjs/engine-render'; +import { ComponentManager, IconManager } from '@univerjs/ui'; +import { describe, expect, it, vi } from 'vitest'; +import { UniverSheetsUIPlugin } from '../plugin'; +import { SheetSkeletonManagerService } from '../services/sheet-skeleton-manager.service'; + +describe('UniverSheetsUIPlugin', () => { + it('registers basic render modules during Starting', () => { + const univer = new Univer(); + const injector = univer.__getInjector(); + injector.add([ComponentManager]); + injector.add([IconManager]); + injector.add([IRenderManagerService, { useClass: RenderManagerService }]); + + const renderManagerService = injector.get(IRenderManagerService); + const registerRenderModule = vi.spyOn(renderManagerService, 'registerRenderModule'); + const plugin = new UniverSheetsUIPlugin( + undefined, + injector, + renderManagerService, + injector.get(IConfigService), + injector.get(IUniverInstanceService) + ); + + plugin.onStarting(); + + expect(registerRenderModule).toHaveBeenCalledWith( + UniverInstanceType.UNIVER_SHEET, + [SheetSkeletonManagerService] + ); + + univer.dispose(); + }); +}); diff --git a/packages/sheets-ui/src/plugin.ts b/packages/sheets-ui/src/plugin.ts index 30307b291d..b9421d2f99 100644 --- a/packages/sheets-ui/src/plugin.ts +++ b/packages/sheets-ui/src/plugin.ts @@ -204,6 +204,10 @@ export class UniverSheetsUIPlugin extends Plugin { [SheetPermissionInterceptorClipboardController], [SheetPermissionCheckUIController], ] as Dependency[], this._config.override)); + + // A sheet renderer can be created before Ready when another sheet-type plugin + // eagerly resolves SheetsRenderService during Starting. + this._registerRenderBasics(); } override onReady(): void { @@ -215,8 +219,6 @@ export class UniverSheetsUIPlugin extends Plugin { [SheetClipboardController], ]); - this._registerRenderBasics(); - touchDependencies(this._injector, [ [SheetUIController], [SheetsRenderService], diff --git a/packages/sheets-ui/src/services/__tests__/sheet-loading-render.service.spec.ts b/packages/sheets-ui/src/services/__tests__/sheet-loading-render.service.spec.ts index a772526a87..fdc42cd414 100644 --- a/packages/sheets-ui/src/services/__tests__/sheet-loading-render.service.spec.ts +++ b/packages/sheets-ui/src/services/__tests__/sheet-loading-render.service.spec.ts @@ -15,7 +15,6 @@ */ import type { IWorkbookData } from '@univerjs/core'; -import type { ILayoutService } from '@univerjs/ui'; import { ContextService, DesktopLogService, @@ -25,10 +24,14 @@ import { InterceptorEffectEnum, IUniverInstanceService, LocaleType, + ThemeService, toDisposable, UniverInstanceService, + Workbook, } from '@univerjs/core'; +import { Engine, IRenderManagerService, RenderManagerService, RenderUnit, Scene } from '@univerjs/engine-render'; import { INTERCEPTOR_POINT, SheetInterceptorService } from '@univerjs/sheets'; +import { ILayoutService } from '@univerjs/ui'; import { describe, expect, it, vi } from 'vitest'; import { SheetLoadingRenderService } from '../sheet-loading-render.service'; @@ -38,12 +41,19 @@ vi.mock('@univerjs/engine-render', async (importOriginal) => { class MockScene { disableObjectsEvent = vi.fn(); makeDirty = vi.fn(); + render = vi.fn(); + requestRender = vi.fn(async () => {}); dispose = vi.fn(); } class MockEngine { private readonly _canvasElement = document.createElement('canvas'); + constructor() { + this._canvasElement.style.position = 'absolute'; + this._canvasElement.style.zIndex = '8'; + } + getCanvas() { return { setId: (id: string) => { this._canvasElement.id = id; }, @@ -51,6 +61,10 @@ vi.mock('@univerjs/engine-render', async (importOriginal) => { }; } + getCanvasElement() { + return this._canvasElement; + } + mount(element: HTMLElement) { element.append(this._canvasElement); } @@ -93,28 +107,38 @@ vi.mock('@univerjs/engine-render', async (importOriginal) => { }; }); +function createLayoutService(contentElement: HTMLElement): ILayoutService { + return { + isFocused: false, + rootContainerElement: null, + focus() { }, + registerFocusHandler: () => toDisposable(() => {}), + registerRootContainerElement: () => toDisposable(() => {}), + registerContentElement: () => toDisposable(() => {}), + registerContainerElement: () => toDisposable(() => {}), + getContentElement: () => contentElement, + checkElementInCurrentContainers: () => false, + checkContentIsFocused: () => false, + }; +} + describe('SheetLoadingRenderService', () => { - it('renders intercepted display values without owning the global workbook or pointer events', () => { + it('renders intercepted display values without owning the global workbook or pointer events', async () => { const contentElement = document.createElement('div'); contentElement.style.pointerEvents = 'auto'; vi.spyOn(contentElement, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 0, 640, 480)); + const animationFrames: FrameRequestCallback[] = []; + const requestAnimationFrame = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => { + animationFrames.push(callback); + return animationFrames.length; + }); - const layoutService: ILayoutService = { - isFocused: false, - rootContainerElement: null, - focus() { }, - registerFocusHandler: () => toDisposable(() => {}), - registerRootContainerElement: () => toDisposable(() => {}), - registerContentElement: () => toDisposable(() => {}), - registerContainerElement: () => toDisposable(() => {}), - getContentElement: () => contentElement, - checkElementInCurrentContainers: () => false, - checkContentIsFocused: () => false, - }; + const layoutService = createLayoutService(contentElement); const injector = new Injector(); injector.add([ILogService, { useClass: DesktopLogService }]); injector.add([IContextService, { useClass: ContextService }]); injector.add([IUniverInstanceService, { useClass: UniverInstanceService }]); + injector.add([ThemeService]); injector.add([SheetInterceptorService]); const interceptorService = injector.get(SheetInterceptorService); const displayInterceptor = vi.fn((cell, context, next) => next({ ...cell, v: '1,234' })); @@ -123,7 +147,9 @@ describe('SheetLoadingRenderService', () => { handler: displayInterceptor, }); - const service = new SheetLoadingRenderService(injector, layoutService, interceptorService); + injector.add([ILayoutService, { useValue: layoutService }]); + injector.add([IRenderManagerService, { useClass: RenderManagerService }]); + const service = injector.createInstance(SheetLoadingRenderService); const workbookData = { id: 'unit-1', name: 'Workbook', @@ -139,12 +165,33 @@ describe('SheetLoadingRenderService', () => { }, resources: [], } satisfies IWorkbookData; + const finalWorkbook = injector.createInstance(Workbook, workbookData); + const finalEngine = new Engine('unit-1'); + const finalScene = new Scene('unit-1-scene', finalEngine); + const finalRender = injector.createInstance(RenderUnit, { + unit: finalWorkbook, + engine: finalEngine, + scene: finalScene, + isMainScene: true, + }); + injector.get(IRenderManagerService).addRender('unit-1', finalRender); + + service.showSkeleton(workbookData, 'sheet-1'); + + expect(service.loading$.value).toBe(true); + expect(service.previewReady$.value).toBe(false); + expect(contentElement.querySelector('canvas')).toBeNull(); service.show(workbookData, 'sheet-1', { 2: { 3: { v: 1234 } } }); + finalEngine.mount(contentElement); + animationFrames[0](0); + await vi.waitFor(() => expect(finalEngine.getCanvasElement().style.visibility).toBe('hidden')); const previewWorkbook = service.workbook$.value; expect(contentElement.style.pointerEvents).toBe('auto'); expect(contentElement.querySelector('canvas')?.style.pointerEvents).toBe('none'); + expect(Array.from(contentElement.querySelectorAll('canvas')).map((canvas) => canvas.style.zIndex)).toEqual(['9', '8']); + expect(service.previewReady$.value).toBe(true); expect(previewWorkbook?.getSheetBySheetId('sheet-1')?.getCellRaw(2, 3)?.v).toBe('1,234'); expect(displayInterceptor).toHaveBeenCalledWith( { v: 1234 }, @@ -159,10 +206,139 @@ describe('SheetLoadingRenderService', () => { expect.any(Function) ); - service.hide('unit-1'); + const handoff = service.handoff('unit-1', 10_000); + await vi.waitFor(() => expect(animationFrames).toHaveLength(2)); + + expect(finalScene.makeDirty).toHaveBeenCalledWith(true); + expect(finalScene.requestRender).toHaveBeenCalledTimes(1); + expect(service.loading$.value).toBe(true); + expect(contentElement.querySelectorAll('canvas')).toHaveLength(2); + expect(finalEngine.getCanvasElement().style.visibility).toBe('hidden'); + + animationFrames[1](0); + await expect(handoff).resolves.toBe(true); expect(service.loading$.value).toBe(false); - expect(contentElement.querySelector('canvas')).toBeNull(); + expect(service.previewReady$.value).toBe(false); + expect(contentElement.querySelectorAll('canvas')).toHaveLength(1); + expect(contentElement.querySelector('canvas')?.style.zIndex).toBe('8'); + expect(finalEngine.getCanvasElement().style.visibility).toBe(''); service.dispose(); + injector.get(IRenderManagerService).dispose(); + finalWorkbook.dispose(); + finalEngine.dispose(); + requestAnimationFrame.mockRestore(); + }); + + it('keeps the preview mounted when the final renderer times out', async () => { + vi.useFakeTimers(); + const contentElement = document.createElement('div'); + vi.spyOn(contentElement, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 0, 640, 480)); + const injector = new Injector(); + injector.add([ILogService, { useClass: DesktopLogService }]); + injector.add([IContextService, { useClass: ContextService }]); + injector.add([IUniverInstanceService, { useClass: UniverInstanceService }]); + injector.add([ThemeService]); + injector.add([SheetInterceptorService]); + injector.add([ILayoutService, { useValue: createLayoutService(contentElement) }]); + injector.add([IRenderManagerService, { useClass: RenderManagerService }]); + const service = injector.createInstance(SheetLoadingRenderService); + const workbookData = { + id: 'unit-1', + name: 'Workbook', + appVersion: '', + locale: LocaleType.EN_US, + styles: {}, + sheetOrder: ['sheet-1'], + sheets: { 'sheet-1': { id: 'sheet-1', cellData: {} } }, + resources: [], + } satisfies IWorkbookData; + service.showSkeleton(workbookData, 'sheet-1'); + service.show(workbookData, 'sheet-1', {}); + + const handoff = service.handoff('unit-1', 10_000); + await vi.advanceTimersByTimeAsync(10_000); + + await expect(handoff).resolves.toBe(false); + expect(service.loading$.value).toBe(true); + expect(contentElement.querySelector('canvas')).not.toBeNull(); + service.dispose(); + injector.get(IRenderManagerService).dispose(); + vi.useRealTimers(); + }); + + it('keeps the preview mounted until the final canvas is mounted', async () => { + const contentElement = document.createElement('div'); + vi.spyOn(contentElement, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 0, 640, 480)); + const animationFrames: FrameRequestCallback[] = []; + const requestAnimationFrame = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => { + animationFrames.push(callback); + return animationFrames.length; + }); + + const injector = new Injector(); + injector.add([ILogService, { useClass: DesktopLogService }]); + injector.add([IContextService, { useClass: ContextService }]); + injector.add([IUniverInstanceService, { useClass: UniverInstanceService }]); + injector.add([ThemeService]); + injector.add([SheetInterceptorService]); + injector.add([ILayoutService, { useValue: createLayoutService(contentElement) }]); + injector.add([IRenderManagerService, { useClass: RenderManagerService }]); + const service = injector.createInstance(SheetLoadingRenderService); + const workbookData = { + id: 'unit-1', + name: 'Workbook', + appVersion: '', + locale: LocaleType.EN_US, + styles: {}, + sheetOrder: ['sheet-1'], + sheets: { 'sheet-1': { id: 'sheet-1', cellData: {} } }, + resources: [], + } satisfies IWorkbookData; + const finalWorkbook = injector.createInstance(Workbook, workbookData); + const finalEngine = new Engine('unit-1'); + const finalScene = new Scene('unit-1-scene', finalEngine); + const finalRender = injector.createInstance(RenderUnit, { + unit: finalWorkbook, + engine: finalEngine, + scene: finalScene, + isMainScene: true, + }); + injector.get(IRenderManagerService).addRender('unit-1', finalRender); + + service.showSkeleton(workbookData, 'sheet-1'); + service.show(workbookData, 'sheet-1', {}); + + const handoff = service.handoff('unit-1', 10_000); + await vi.waitFor(() => expect(animationFrames).toHaveLength(2)); + + expect(finalScene.requestRender).not.toHaveBeenCalled(); + expect(service.loading$.value).toBe(true); + expect(contentElement.querySelectorAll('canvas')).toHaveLength(1); + + animationFrames[1](0); + await vi.waitFor(() => expect(animationFrames).toHaveLength(3)); + expect(finalScene.requestRender).not.toHaveBeenCalled(); + + finalEngine.mount(contentElement); + animationFrames[2](0); + await vi.waitFor(() => expect(animationFrames).toHaveLength(4)); + + expect(finalScene.requestRender).toHaveBeenCalledTimes(1); + expect(service.loading$.value).toBe(true); + expect(contentElement.querySelectorAll('canvas')).toHaveLength(2); + expect(finalEngine.getCanvasElement().style.visibility).toBe('hidden'); + + animationFrames[3](0); + await expect(handoff).resolves.toBe(true); + + expect(service.loading$.value).toBe(false); + expect(contentElement.querySelectorAll('canvas')).toHaveLength(1); + expect(finalEngine.getCanvasElement().style.visibility).toBe(''); + service.dispose(); + injector.get(IRenderManagerService).dispose(); + finalWorkbook.dispose(); + finalEngine.dispose(); + requestAnimationFrame.mockRestore(); }); }); diff --git a/packages/sheets-ui/src/services/sheet-loading-render.service.ts b/packages/sheets-ui/src/services/sheet-loading-render.service.ts index 5a3e9547d8..dab81348c4 100644 --- a/packages/sheets-ui/src/services/sheet-loading-render.service.ts +++ b/packages/sheets-ui/src/services/sheet-loading-render.service.ts @@ -15,24 +15,29 @@ */ import type { ICellData, IObjectMatrixPrimitiveType, IWorkbookData } from '@univerjs/core'; -import type { Observable } from 'rxjs'; +import type { IRender } from '@univerjs/engine-render'; +import type { Subscription } from 'rxjs'; import { createIdentifier, Disposable, Inject, Injector, InterceptorEffectEnum, ObjectMatrix, Workbook } from '@univerjs/core'; -import { Engine, RenderUnit, Scene, Spreadsheet } from '@univerjs/engine-render'; +import { Engine, IRenderManagerService, RenderUnit, Scene, Spreadsheet } from '@univerjs/engine-render'; import { INTERCEPTOR_POINT, SheetInterceptorService } from '@univerjs/sheets'; import { ILayoutService } from '@univerjs/ui'; -import { BehaviorSubject } from 'rxjs'; +import { BehaviorSubject, filter, firstValueFrom, from, map, Observable, of, switchMap, take, timeout } from 'rxjs'; import { SheetRenderController } from '../controllers/render-controllers/sheet.render-controller'; import { SheetSkeletonRenderController } from '../controllers/render-controllers/skeleton.render-controller'; import { SheetSkeletonManagerService } from './sheet-skeleton-manager.service'; const PREVIEW_UNIT_SUFFIX = '__snapshot_loading_preview__'; const PREVIEW_SCENE_PREFIX = '_UNIVER_SCENE_'; +const PREVIEW_CANVAS_Z_INDEX = '9'; export interface ISheetLoadingRenderService { readonly workbook$: Observable; readonly loading$: Observable; + readonly previewReady$: Observable; + showSkeleton(workbookData: IWorkbookData, activeSheetId: string): void; show(workbookData: IWorkbookData, activeSheetId: string, cellData?: IObjectMatrixPrimitiveType): void; + handoff(sourceUnitId: string, timeoutMs: number): Promise; hide(sourceUnitId: string): void; } @@ -41,7 +46,7 @@ export const ISheetLoadingRenderService = createIdentifier; } @@ -52,35 +57,61 @@ interface ILoadingRender { export class SheetLoadingRenderService extends Disposable implements ISheetLoadingRenderService { readonly workbook$ = new BehaviorSubject(null); readonly loading$ = new BehaviorSubject(false); + readonly previewReady$ = new BehaviorSubject(false); private _loadingRender: ILoadingRender | null = null; + private _finalCanvasGuard: Subscription | null = null; + private _hiddenFinalCanvas: HTMLCanvasElement | null = null; + private _finalCanvasVisibility = ''; constructor( @Inject(Injector) private readonly _injector: Injector, @ILayoutService private readonly _layoutService: ILayoutService, - @Inject(SheetInterceptorService) private readonly _sheetInterceptorService: SheetInterceptorService + @Inject(SheetInterceptorService) private readonly _sheetInterceptorService: SheetInterceptorService, + @IRenderManagerService private readonly _renderManagerService: IRenderManagerService ) { super(); } + showSkeleton(workbookData: IWorkbookData, activeSheetId: string): void { + const sourceUnitId = workbookData.id; + if (this._loadingRender?.sourceUnitId !== sourceUnitId) { + this._disposeLoadingRender(); + this.previewReady$.next(false); + this._loadingRender = { + sourceUnitId, + workbook: this._createLoadingWorkbook(workbookData, activeSheetId), + render: null, + }; + this.workbook$.next(this._loadingRender.workbook); + this.loading$.next(true); + this._guardFinalCanvas(sourceUnitId); + } + + const worksheet = this._loadingRender.workbook.getSheetBySheetId(activeSheetId); + if (worksheet && this._loadingRender.workbook.getActiveSheet().getSheetId() !== activeSheetId) { + this._loadingRender.workbook.setActiveSheet(worksheet); + } + } + show( workbookData: IWorkbookData, activeSheetId: string, cellData?: IObjectMatrixPrimitiveType ): void { - const sourceUnitId = workbookData.id; - if (this._loadingRender?.sourceUnitId !== sourceUnitId) { - this._disposeLoadingRender(); - this._loadingRender = this._createLoadingRender(workbookData, activeSheetId); - this.workbook$.next(this._loadingRender.workbook); - this.loading$.next(true); - } + this.showSkeleton(workbookData, activeSheetId); const loadingRender = this._loadingRender; if (!loadingRender) { return; } + let shouldRender = false; + if (!loadingRender.render) { + loadingRender.render = this._createLoadingRender(loadingRender.workbook, loadingRender.sourceUnitId); + shouldRender = true; + } + const worksheet = loadingRender.workbook.getSheetBySheetId(activeSheetId); if (!worksheet) { return; @@ -89,6 +120,7 @@ export class SheetLoadingRenderService extends Disposable implements ISheetLoadi if (loadingRender.workbook.getActiveSheet().getSheetId() !== activeSheetId) { loadingRender.workbook.setActiveSheet(worksheet); loadingRender.render.with(SheetSkeletonManagerService).setCurrent({ sheetId: activeSheetId }); + shouldRender = true; } if (cellData && cellData !== loadingRender.lastCellData) { @@ -118,6 +150,40 @@ export class SheetLoadingRenderService extends Disposable implements ISheetLoadi loadingRender.render.mainComponent.makeForceDirty(true); } loadingRender.render.scene.makeDirty(true); + shouldRender = true; + } + + if (shouldRender) { + loadingRender.render.scene.render(); + } + if (!this.previewReady$.value) { + this.previewReady$.next(true); + } + } + + async handoff(sourceUnitId: string, timeoutMs: number): Promise { + if (this._loadingRender?.sourceUnitId !== sourceUnitId) { + return true; + } + + this._finalCanvasGuard?.unsubscribe(); + this._finalCanvasGuard = null; + + try { + await firstValueFrom(this._getFinalRender$(sourceUnitId).pipe( + take(1), + switchMap((render) => this._waitForFinalCanvas(render).pipe( + switchMap(() => { + this._hideFinalCanvas(render); + return this._requestFinalRender(render); + }) + )), + timeout(timeoutMs) + )); + this.hide(sourceUnitId); + return true; + } catch { + return false; } } @@ -133,10 +199,11 @@ export class SheetLoadingRenderService extends Disposable implements ISheetLoadi this._disposeLoadingRender(); this.workbook$.complete(); this.loading$.complete(); + this.previewReady$.complete(); super.dispose(); } - private _createLoadingRender(workbookData: IWorkbookData, activeSheetId: string): ILoadingRender { + private _createLoadingWorkbook(workbookData: IWorkbookData, activeSheetId: string): Workbook { const previewUnitId = `${workbookData.id}${PREVIEW_UNIT_SUFFIX}`; const workbook = this._injector.createInstance(Workbook, { ...workbookData, @@ -147,6 +214,11 @@ export class SheetLoadingRenderService extends Disposable implements ISheetLoadi workbook.setActiveSheet(activeSheet); } + return workbook; + } + + private _createLoadingRender(workbook: Workbook, sourceUnitId: string): RenderUnit { + const previewUnitId = workbook.getUnitId(); const contentElement = this._layoutService.getContentElement(); const { width, height } = contentElement.getBoundingClientRect(); const engine = this._injector.createInstance(Engine, previewUnitId, undefined); @@ -170,28 +242,124 @@ export class SheetLoadingRenderService extends Disposable implements ISheetLoadi ]); const canvas = engine.getCanvas(); - canvas.setId(`univer-sheet-loading-canvas_${workbookData.id}`); - canvas.getCanvasEle().dataset.uUnitId = workbookData.id; + canvas.setId(`univer-sheet-loading-canvas_${sourceUnitId}`); + canvas.getCanvasEle().dataset.uUnitId = sourceUnitId; canvas.getCanvasEle().style.pointerEvents = 'none'; + canvas.getCanvasEle().style.zIndex = PREVIEW_CANVAS_Z_INDEX; engine.mount(contentElement); - return { sourceUnitId: workbookData.id, workbook, render }; + return render; } catch (error) { this._disposeRender(render); - workbook.dispose(); throw error; } } + private _waitForFinalCanvas(render: IRender): Observable { + return new Observable((subscriber) => { + let frameId: number | undefined; + const contentElement = this._layoutService.getContentElement(); + const canvasElement = render.engine.getCanvasElement(); + + const checkMounted = () => { + if ( + canvasElement.parentElement === contentElement && + canvasElement.width > 0 && + canvasElement.height > 0 + ) { + subscriber.next(); + subscriber.complete(); + return; + } + + frameId = window.requestAnimationFrame(checkMounted); + }; + + checkMounted(); + + return () => { + if (frameId !== undefined) { + window.cancelAnimationFrame(frameId); + } + }; + }); + } + + private _getFinalRender$(sourceUnitId: string): Observable { + const existingRender = this._renderManagerService.getRenderById(sourceUnitId); + return existingRender + ? of(existingRender) + : this._renderManagerService.created$.pipe( + filter((render) => render.unitId === sourceUnitId) + ); + } + + private _guardFinalCanvas(sourceUnitId: string): void { + this._finalCanvasGuard?.unsubscribe(); + this._finalCanvasGuard = this._getFinalRender$(sourceUnitId).pipe( + take(1), + switchMap((render) => this._waitForFinalCanvas(render).pipe(map(() => render))) + ).subscribe((render) => this._hideFinalCanvas(render)); + } + + private _requestFinalRender(render: IRender): Observable { + render.scene.makeDirty(true); + return from(render.scene.requestRender()).pipe( + // Scene.requestRender resolves from a requestAnimationFrame callback, before that + // frame is painted. Keep the preview above the final canvas for one more frame so + // the browser paints the final canvas before the preview is disposed. + switchMap(() => this._waitForNextAnimationFrame()) + ); + } + + private _hideFinalCanvas(render: IRender): void { + const canvasElement = render.engine.getCanvasElement(); + if (this._hiddenFinalCanvas === canvasElement) { + return; + } + + this._restoreFinalCanvas(); + this._hiddenFinalCanvas = canvasElement; + this._finalCanvasVisibility = canvasElement.style.visibility; + canvasElement.style.visibility = 'hidden'; + } + + private _restoreFinalCanvas(): void { + if (!this._hiddenFinalCanvas) { + return; + } + + this._hiddenFinalCanvas.style.visibility = this._finalCanvasVisibility; + this._hiddenFinalCanvas = null; + this._finalCanvasVisibility = ''; + } + + private _waitForNextAnimationFrame(): Observable { + return new Observable((subscriber) => { + const frameId = window.requestAnimationFrame(() => { + subscriber.next(); + subscriber.complete(); + }); + + return () => window.cancelAnimationFrame(frameId); + }); + } + private _disposeLoadingRender(): void { + this._finalCanvasGuard?.unsubscribe(); + this._finalCanvasGuard = null; + this._restoreFinalCanvas(); const loadingRender = this._loadingRender; this._loadingRender = null; this.workbook$.next(null); this.loading$.next(false); + this.previewReady$.next(false); if (!loadingRender) { return; } - this._disposeRender(loadingRender.render); + if (loadingRender.render) { + this._disposeRender(loadingRender.render); + } loadingRender.workbook.dispose(); } diff --git a/packages/sheets-ui/src/views/hook.ts b/packages/sheets-ui/src/views/hook.ts index 88a9e3d5bc..5faf0fd710 100644 --- a/packages/sheets-ui/src/views/hook.ts +++ b/packages/sheets-ui/src/views/hook.ts @@ -29,7 +29,7 @@ export function useActiveWorkbook(): Workbook | null { const univerInstanceService = useDependency(IUniverInstanceService); const loadingWorkbook = useContext(SheetLoadingWorkbookContext); const workbook = useObservable(() => univerInstanceService.getCurrentTypeOfUnit$(UniverInstanceType.UNIVER_SHEET), undefined, undefined, []); - return workbook ?? loadingWorkbook ?? null; + return loadingWorkbook ?? workbook ?? null; } export function useSheetLoadingWorkbook(): Workbook | null { @@ -42,6 +42,11 @@ export function useSheetLoading(): boolean { return useObservable(loadingRenderService.loading$) ?? false; } +export function useSheetLoadingPreviewReady(): boolean { + const loadingRenderService = useDependency(ISheetLoadingRenderService); + return useObservable(loadingRenderService.previewReady$) ?? false; +} + export function useActiveWorksheet(workbook?: Workbook | null) { const worksheet = useObservable(() => workbook?.activeSheet$ ?? of(null), undefined, undefined, [workbook]); return worksheet; diff --git a/packages/sheets-ui/src/views/sheet-container/SheetContainer.tsx b/packages/sheets-ui/src/views/sheet-container/SheetContainer.tsx index 66fdf0c253..118b3dd572 100644 --- a/packages/sheets-ui/src/views/sheet-container/SheetContainer.tsx +++ b/packages/sheets-ui/src/views/sheet-container/SheetContainer.tsx @@ -16,8 +16,18 @@ import type { Workbook, Worksheet } from '@univerjs/core'; import type { IUniverSheetsUIConfig } from '../../config/config'; -import { Injector, isInternalEditorID, IUniverInstanceService, UniverInstanceType } from '@univerjs/core'; +import { + DEFAULT_WORKSHEET_COLUMN_TITLE_HEIGHT, + DEFAULT_WORKSHEET_COLUMN_WIDTH, + DEFAULT_WORKSHEET_ROW_HEIGHT, + DEFAULT_WORKSHEET_ROW_TITLE_WIDTH, + Injector, + isInternalEditorID, + IUniverInstanceService, + UniverInstanceType, +} from '@univerjs/core'; import { clsx } from '@univerjs/design'; +import { LoadingMultiIcon } from '@univerjs/icons'; import { ComponentManager, ContextMenuPosition, IMenuManagerService, ToolbarItem, useConfigValue, useDependency, useObservable } from '@univerjs/ui'; import { useEffect, useMemo } from 'react'; import { SHEETS_UI_PLUGIN_CONFIG_KEY } from '../../config/config'; @@ -31,6 +41,7 @@ import { useActiveWorkbook, useActiveWorksheet, useSheetLoading, + useSheetLoadingPreviewReady, useSheetLoadingWorkbook, } from '../hook'; import { SheetBar } from '../sheet-bar/SheetBar'; @@ -45,7 +56,7 @@ export function RenderSheetFooter() { const showFooter = config?.footer ?? true; const activeWorkbook = useActiveWorkbook(); const loadingWorkbook = useSheetLoadingWorkbook(); - const workbook = activeWorkbook ?? loadingWorkbook; + const workbook = loadingWorkbook ?? activeWorkbook; const isLoading = useSheetLoading(); const activeWorkbookEmbeddedRender = useActiveWorkbookIsEmbeddedRender(workbook); const focusedUnitType = useFocusedUnitType(); @@ -104,7 +115,7 @@ export function RenderSheetHeader() { const config = useConfigValue(SHEETS_UI_PLUGIN_CONFIG_KEY); const activeWorkbook = useActiveWorkbook(); const loadingWorkbook = useSheetLoadingWorkbook(); - const workbook = activeWorkbook ?? loadingWorkbook; + const workbook = loadingWorkbook ?? activeWorkbook; const isLoading = useSheetLoading(); const hasWorkbook = !!workbook; const activeWorkbookEmbeddedRender = useActiveWorkbookIsEmbeddedRender(workbook); @@ -144,8 +155,11 @@ export function RenderSheetHeader() { export function RenderSheetContent() { const config = useConfigValue(SHEETS_UI_PLUGIN_CONFIG_KEY); const isLoading = useSheetLoading(); + const isPreviewReady = useSheetLoadingPreviewReady(); const componentManager = useDependency(ComponentManager); - const workbook = useActiveWorkbook(); + const activeWorkbook = useActiveWorkbook(); + const loadingWorkbook = useSheetLoadingWorkbook(); + const workbook = loadingWorkbook ?? activeWorkbook; const activeEmbedTab = useActiveSheetEmbedTabData(workbook); const injector = useDependency(Injector); const activeWorkbookEmbeddedRender = useActiveWorkbookIsEmbeddedRender(workbook); @@ -164,7 +178,8 @@ export function RenderSheetContent() { tryGetSheetEmbedRuntimeService(injector)?.clearTab(); }, [activeEmbedTab, activeWorkbookEmbeddedRender, injector, isLoading, workbook]); - if (!workbook || isLoading) return null; + if (!workbook) return null; + if (isLoading) return isPreviewReady ? null : ; if (activeWorkbookEmbeddedRender) return null; if (activeEmbedTab && workbook) { return ; @@ -179,6 +194,53 @@ export function RenderSheetContent() { ); } +function SheetLoadingSkeleton() { + return ( +
+
+
+
+ +
+ ); +} + function RenderSheetEmbedTabHost(props: { workbook: Workbook; worksheet: Worksheet }) { const { workbook, worksheet } = props; const injector = useDependency(Injector);