diff --git a/packages/core/src/services/instance/__tests__/instance.service.spec.ts b/packages/core/src/services/instance/__tests__/instance.service.spec.ts index e53509a01075..2578e218e1c1 100644 --- a/packages/core/src/services/instance/__tests__/instance.service.spec.ts +++ b/packages/core/src/services/instance/__tests__/instance.service.spec.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import type { Nullable } from '../../../shared/types'; import type { IWorkbookData } from '../../../sheets/typedef'; import type { Workbook } from '../../../sheets/workbook'; import type { IDocumentData } from '../../../types/interfaces/i-document-data'; @@ -217,4 +218,23 @@ describe('UniverInstanceService', () => { expect(service.getAllUnitsForType(UniverInstanceType.UNIVER_BOARD)).toEqual([board]); expect(service.getUnitType('board-unit')).toBe(UniverInstanceType.UNIVER_BOARD); }); + + it('does not rebroadcast unchanged current or focused units', () => { + const workbook = service.createUnit, WorkbookModel>(UniverInstanceType.UNIVER_SHEET, createWorkbookData()); + const currentIds: Array = []; + const focusedIds: Array> = []; + service.getCurrentTypeOfUnit$(UniverInstanceType.UNIVER_SHEET).subscribe((unit) => { + currentIds.push(unit?.getUnitId() ?? null); + }); + service.focused$.subscribe((unitId) => { + focusedIds.push(unitId); + }); + + service.setCurrentUnitForType(workbook.getUnitId()); + service.focusUnit(workbook.getUnitId()); + service.focusUnit(workbook.getUnitId()); + + expect(currentIds).toEqual([workbook.getUnitId()]); + expect(focusedIds).toEqual([null, workbook.getUnitId()]); + }); }); diff --git a/packages/core/src/services/instance/instance.service.ts b/packages/core/src/services/instance/instance.service.ts index ec83340c1211..52c6b5872ee4 100644 --- a/packages/core/src/services/instance/instance.service.ts +++ b/packages/core/src/services/instance/instance.service.ts @@ -38,6 +38,28 @@ export interface ICreateUnitOptions { * @default true */ makeCurrent?: boolean; + /** + * If product UI render services should skip their default main-canvas render + * creation. Embedded units create their render explicitly into a host-owned + * container instead. + * + * @default false + */ + skipAutoRender?: boolean; + /** + * If render services should create the render unit as an embedded/non-main + * render. Embedded renders are mounted explicitly by a host container and + * must not mutate global workbench state while resolving their local view. + * + * @default false + */ + embeddedRender?: boolean; + /** + * Optional parent injector for an embedded render unit. Render modules will + * resolve their dependencies from this injector before falling back to the + * root injector. + */ + renderParentInjector?: Injector; } interface ICreateUnitEvent { @@ -84,6 +106,8 @@ export interface IUniverInstanceService { /** Create a unit with snapshot info. */ createUnit(type: UniverInstanceType, data: Partial, options?: ICreateUnitOptions): U; + /** Get the options originally used to create a unit. */ + getUnitCreateOptions(unitId: string): Nullable; /** Dispose a unit */ disposeUnit(unitId: string): boolean; @@ -103,6 +127,7 @@ export interface IUniverInstanceService { export const IUniverInstanceService = createIdentifier('univer.current'); export class UniverInstanceService extends Disposable implements IUniverInstanceService { private readonly _unitsByType = new Map(); + private readonly _unitCreateOptions = new Map(); constructor( @Inject(Injector) private readonly _injector: Injector, @@ -122,6 +147,7 @@ export class UniverInstanceService extends Disposable implements IUniverInstance this._currentUnits.forEach((unit) => unit?.dispose()); this._currentUnits.clear(); this._unitsByType.clear(); + this._unitCreateOptions.clear(); } private _createHandler!: ( @@ -169,6 +195,9 @@ export class UniverInstanceService extends Disposable implements IUniverInstance setCurrentUnitForType(unitId: string): void { const result = this._getUnitById(unitId); if (!result) throw new Error(`[UniverInstanceService]: no document with unitId ${unitId}!`); + if (this._currentUnits.get(result[1]) === result[0]) { + return; + } this._currentUnits.set(result[1], result[0]); this._currentUnits$.next(this._currentUnits); @@ -202,6 +231,9 @@ export class UniverInstanceService extends Disposable implements IUniverInstance } units.push(unit); + if (options) { + this._unitCreateOptions.set(newUnitId, { ...options }); + } this._unitAdded$.next({ unit, options }); if (options?.makeCurrent ?? true) { @@ -221,6 +253,10 @@ export class UniverInstanceService extends Disposable implements IUniverInstance return unit; } + getUnitCreateOptions(unitId: string): Nullable { + return this._unitCreateOptions.get(unitId) ?? null; + } + getUniverSheetInstance(unitId: string): Nullable { return this.getUnit(unitId, UniverInstanceType.UNIVER_SHEET); } @@ -251,6 +287,10 @@ export class UniverInstanceService extends Disposable implements IUniverInstance } focusUnit(id: string | null): void { + if (this._focused$.getValue() === id) { + return; + } + this._focused$.next(id); if (this.focused instanceof Workbook) { @@ -308,6 +348,7 @@ export class UniverInstanceService extends Disposable implements IUniverInstance this._tryResetFocusOnRemoval(unitId); this._unitDisposed$.next(unit); + this._unitCreateOptions.delete(unitId); unit.dispose(); diff --git a/packages/core/src/services/resource-loader/__tests__/resource-loader.service.spec.ts b/packages/core/src/services/resource-loader/__tests__/resource-loader.service.spec.ts index bb2ff03a0b94..71214e883755 100644 --- a/packages/core/src/services/resource-loader/__tests__/resource-loader.service.spec.ts +++ b/packages/core/src/services/resource-loader/__tests__/resource-loader.service.spec.ts @@ -28,8 +28,10 @@ describe('ResourceLoaderService', () => { let sheetAdded$: Subject; let docAdded$: Subject; let slideAdded$: Subject; + let baseAdded$: Subject; let sheetDisposed$: Subject; let docDisposed$: Subject; + let baseDisposed$: Subject; let slideDisposed$: Subject; let resourceManagerService: { getAllResourceHooks: ReturnType; @@ -50,8 +52,10 @@ describe('ResourceLoaderService', () => { sheetAdded$ = new Subject(); docAdded$ = new Subject(); slideAdded$ = new Subject(); + baseAdded$ = new Subject(); sheetDisposed$ = new Subject(); docDisposed$ = new Subject(); + baseDisposed$ = new Subject(); slideDisposed$ = new Subject(); resourceManagerService = { getAllResourceHooks: vi.fn(() => []), @@ -65,11 +69,13 @@ describe('ResourceLoaderService', () => { getTypeOfUnitAdded$: vi.fn((type) => { if (type === UniverInstanceType.UNIVER_SHEET) return sheetAdded$; if (type === UniverInstanceType.UNIVER_DOC) return docAdded$; + if (type === UniverInstanceType.UNIVER_BASE) return baseAdded$; return slideAdded$; }), getTypeOfUnitDisposed$: vi.fn((type) => { if (type === UniverInstanceType.UNIVER_SHEET) return sheetDisposed$; if (type === UniverInstanceType.UNIVER_DOC) return docDisposed$; + if (type === UniverInstanceType.UNIVER_BASE) return baseDisposed$; return slideDisposed$; }), getUnit: vi.fn(), @@ -109,6 +115,19 @@ describe('ResourceLoaderService', () => { expect(resourceManagerService.unloadResources).toHaveBeenCalledWith('book-1', UniverInstanceType.UNIVER_SHEET); }); + it('loads resources when a base unit is added and unloads them when disposed', () => { + const base = { + getUnitId: () => 'base-1', + getSnapshot: () => ({ resources: [{ name: 'base-plugin', data: '{}' }] }), + }; + + baseAdded$.next({ unit: base }); + baseDisposed$.next(base); + + expect(resourceManagerService.loadResources).toHaveBeenCalledWith('base-1', [{ name: 'base-plugin', data: '{}' }]); + expect(resourceManagerService.unloadResources).toHaveBeenCalledWith('base-1', UniverInstanceType.UNIVER_BASE); + }); + it('saves a unit snapshot with current plugin resources', () => { univerInstanceService.getUnit.mockReturnValue({ type: UniverInstanceType.UNIVER_SHEET, diff --git a/packages/core/src/services/resource-loader/resource-loader.service.ts b/packages/core/src/services/resource-loader/resource-loader.service.ts index ef1658d93de8..dd36781e5fe6 100644 --- a/packages/core/src/services/resource-loader/resource-loader.service.ts +++ b/packages/core/src/services/resource-loader/resource-loader.service.ts @@ -84,6 +84,12 @@ export class ResourceLoaderService extends Disposable implements IResourceLoader }); break; } + case UniverInstanceType.UNIVER_BASE: { + this._univerInstanceService.getAllUnitsForType>(UniverInstanceType.UNIVER_BASE).forEach((base) => { + loadHookResource(hook, base.getUnitId(), base.getSnapshot().resources, 'Base'); + }); + break; + } } }); }; @@ -121,6 +127,12 @@ export class ResourceLoaderService extends Disposable implements IResourceLoader }) ); + this.disposeWithMe( + this._univerInstanceService.getTypeOfUnitAdded$>(UniverInstanceType.UNIVER_BASE).subscribe((event) => { + const { unit: base } = event; + this._resourceManagerService.loadResources(base.getUnitId(), base.getSnapshot().resources); + }) + ); this.disposeWithMe( this._univerInstanceService.getTypeOfUnitDisposed$(UniverInstanceType.UNIVER_SHEET).subscribe((workbook) => { this._resourceManagerService.unloadResources(workbook.getUnitId(), UniverInstanceType.UNIVER_SHEET); @@ -132,6 +144,11 @@ export class ResourceLoaderService extends Disposable implements IResourceLoader this._resourceManagerService.unloadResources(doc.getUnitId(), UniverInstanceType.UNIVER_DOC); }) ); + this.disposeWithMe( + this._univerInstanceService.getTypeOfUnitDisposed$>(UniverInstanceType.UNIVER_BASE).subscribe((base) => { + this._resourceManagerService.unloadResources(base.getUnitId(), UniverInstanceType.UNIVER_BASE); + }) + ); this.disposeWithMe( this._univerInstanceService.getTypeOfUnitDisposed$(UniverInstanceType.UNIVER_SLIDE).subscribe((slide) => { this._resourceManagerService.unloadResources(slide.getUnitId(), UniverInstanceType.UNIVER_SLIDE); diff --git a/packages/core/src/services/resource-manager/type.ts b/packages/core/src/services/resource-manager/type.ts index 914102c3f974..df982f73b297 100644 --- a/packages/core/src/services/resource-manager/type.ts +++ b/packages/core/src/services/resource-manager/type.ts @@ -21,7 +21,7 @@ import { createIdentifier } from '../../common/di'; export type IResources = Array<{ id?: string; name: string; data: string }>; -type IBusinessName = 'SHEET' | 'DOC' | 'SLIDE'; +type IBusinessName = 'SHEET' | 'DOC' | 'SLIDE' | 'BASE' | 'UNIVER'; export type IResourceName = `${IBusinessName}_${string}_PLUGIN`; export interface IResourceHook { pluginName: IResourceName; diff --git a/packages/core/src/types/interfaces/i-drawing.ts b/packages/core/src/types/interfaces/i-drawing.ts index 1611dc45a676..851a390b586e 100644 --- a/packages/core/src/types/interfaces/i-drawing.ts +++ b/packages/core/src/types/interfaces/i-drawing.ts @@ -84,6 +84,10 @@ export enum DrawingTypeEnum { * Dom element, allows inserting HTML elements as floating objects into the document */ DRAWING_DOM = 8, + /** + * Block element, allows host products to place embeddable unit-backed blocks as drawing objects. + */ + DRAWING_BLOCK = 9, } export type DrawingType = DrawingTypeEnum | number; diff --git a/packages/docs-drawing-ui/src/controllers/__tests__/doc-float-dom.controller.spec.ts b/packages/docs-drawing-ui/src/controllers/__tests__/doc-float-dom.controller.spec.ts index b6baa75f5a84..a228bec2e761 100644 --- a/packages/docs-drawing-ui/src/controllers/__tests__/doc-float-dom.controller.spec.ts +++ b/packages/docs-drawing-ui/src/controllers/__tests__/doc-float-dom.controller.spec.ts @@ -22,17 +22,21 @@ import { InsertDocDrawingCommand } from '../../commands/commands/insert-doc-draw import { calcDocFloatDomPositionByRect, DocFloatDomController } from '../doc-float-dom.controller'; function createScene() { + const viewport = { viewportScrollX: 10, viewportScrollY: 20, onScrollAfter$: new Subject() }; return { - getViewport: vi.fn(() => ({ viewportScrollX: 10, viewportScrollY: 20, onScrollAfter$: new Subject() })), + viewport, + getViewport: vi.fn(() => viewport), getAncestorScale: vi.fn(() => ({ scaleX: 2, scaleY: 3 })), getTransformerByCreate: vi.fn(() => ({})), removeObject: vi.fn(), }; } -function createController(options: { rects?: Rect[]; page?: any } = {}) { +function createController(options: { drawing?: Record; rects?: Rect[]; page?: any } = {}) { const add$ = new Subject(); const remove$ = new Subject(); + const refreshTransform$ = new Subject(); + const currentSkeleton$ = new Subject(); const commandHandlers: Array<(command: { id: string; params?: unknown }) => void> = []; const scene = createScene(); const canvas = { dispatchEvent: vi.fn() }; @@ -40,6 +44,7 @@ function createController(options: { rects?: Rect[]; page?: any } = {}) { scene, engine: { getCanvasElement: () => canvas }, with: vi.fn(() => ({ + currentSkeleton$, getSkeleton: () => ({ getSkeletonData: () => ({ pages: [options.page ?? { pageWidth: 240, marginLeft: 20, marginRight: 30 }] }), }), @@ -55,17 +60,29 @@ function createController(options: { rects?: Rect[]; page?: any } = {}) { componentKey: 'FloatDom', drawingType: DrawingTypeEnum.DRAWING_DOM, data: { value: 1 }, + ...options.drawing, }; const drawingManagerService = { add$, remove$, + refreshTransform$, getDrawingByParam: vi.fn(() => drawing), }; const drawingRenderService = { renderFloatDom: vi.fn(async () => options.rects ?? []), }; + const domLayers: Array<[string, any]> = []; const canvasFloatDomService = { - addFloatDom: vi.fn(), + domLayers, + addFloatDom: vi.fn((layer) => { + domLayers.push([layer.id, layer]); + }), + updateFloatDom: vi.fn((id, patch) => { + const layer = domLayers.find(([layerId]) => layerId === id); + if (layer) { + Object.assign(layer[1], patch); + } + }), removeFloatDom: vi.fn(), }; const doc = { getUnitId: () => 'doc-1' }; @@ -94,7 +111,9 @@ function createController(options: { rects?: Rect[]; page?: any } = {}) { return { controller, add$, + currentSkeleton$, remove$, + refreshTransform$, commandHandlers, scene, canvas, @@ -164,6 +183,305 @@ describe('DocFloatDomController', () => { controller.dispose(); }); + it('keeps embed custom block float doms visible when focus moves into child units', async () => { + const rect = new Rect('dom-rect', { + left: 30, + top: 50, + width: 50, + height: 40, + } as never); + const { controller, add$, canvasFloatDomService } = createController({ + rects: [rect], + drawing: { + data: { version: 1, embedId: 'embed-1', hostAnchorId: 'anchor-1' }, + }, + }); + + add$.next([{ unitId: 'doc-1', subUnitId: 'doc-1', drawingId: 'dom-1' }]); + await Promise.resolve(); + + expect(canvasFloatDomService.addFloatDom).toHaveBeenCalledWith(expect.objectContaining({ + eventPassThrough: false, + preserveOnFocusChange: true, + })); + + controller.dispose(); + }); + + it('updates float dom position from its own host viewport scroll even when current doc focus changes', async () => { + const rect = new Rect('dom-rect', { + left: 30, + top: 50, + width: 50, + height: 40, + } as never); + const { controller, add$, scene, canvasFloatDomService } = createController({ rects: [rect] }); + + add$.next([{ unitId: 'doc-1', subUnitId: 'doc-1', drawingId: 'dom-1' }]); + await Promise.resolve(); + + const position$ = canvasFloatDomService.addFloatDom.mock.calls[0][0].position$; + const positions: unknown[] = []; + const sub = position$.subscribe((position: unknown) => positions.push(position)); + + scene.viewport.viewportScrollY = 80; + scene.viewport.onScrollAfter$.next({} as never); + + expect(positions.at(-1)).toMatchObject({ + startX: 40, + startY: -90, + width: 100, + height: 120, + }); + + sub.unsubscribe(); + controller.dispose(); + }); + + it('updates rendered float dom bounds from doc drawing transform refreshes', async () => { + const rect = new Rect('dom-rect', { + left: 30, + top: 50, + width: 50, + height: 40, + } as never); + const { controller, add$, refreshTransform$, canvasFloatDomService } = createController({ + rects: [rect], + drawing: { + data: { version: 1, embedId: 'embed-1', hostAnchorId: 'anchor-1' }, + }, + }); + + add$.next([{ unitId: 'doc-1', subUnitId: 'doc-1', drawingId: 'dom-1' }]); + await Promise.resolve(); + + const position$ = canvasFloatDomService.addFloatDom.mock.calls[0][0].position$; + const positions: unknown[] = []; + const sub = position$.subscribe((position: unknown) => positions.push(position)); + + refreshTransform$.next([{ + drawingId: 'dom-1', + transform: { left: 40, top: 60, width: 160, height: 240, angle: 0 }, + customBlockRenderViewport: { contentHeight: 240, height: 240, viewportHeight: 120 }, + }]); + + expect(positions.at(-1)).toMatchObject({ + startX: 60, + startY: 120, + width: 320, + height: 720, + }); + expect(canvasFloatDomService.updateFloatDom).toHaveBeenCalledWith('dom-1', expect.objectContaining({ + props: expect.objectContaining({ + customBlockRenderViewport: { contentHeight: 240, height: 240, viewportHeight: 120 }, + }), + })); + + sub.unsubscribe(); + controller.dispose(); + }); + + it('uses custom block viewport height when creating the initial float dom position', async () => { + const rect = new Rect('dom-rect', { + left: 30, + top: 50, + width: 50, + height: 40, + } as never); + const { controller, add$, canvasFloatDomService } = createController({ + rects: [rect], + drawing: { + customBlockRenderViewport: { contentHeight: 240, height: 240, viewportHeight: 120 }, + }, + }); + + add$.next([{ unitId: 'doc-1', subUnitId: 'doc-1', drawingId: 'dom-1' }]); + await Promise.resolve(); + + const position$ = canvasFloatDomService.addFloatDom.mock.calls[0][0].position$; + const positions: unknown[] = []; + const sub = position$.subscribe((position: unknown) => positions.push(position)); + + expect(positions.at(-1)).toMatchObject({ + startX: 40, + startY: 90, + width: 100, + height: 720, + }); + + sub.unsubscribe(); + controller.dispose(); + }); + + it('disables host event pass-through for embed float dom runtimes', async () => { + const rect = new Rect('dom-rect', { + left: 30, + top: 50, + width: 50, + height: 40, + } as never); + const { controller, add$, canvasFloatDomService } = createController({ + rects: [rect], + drawing: { + data: { version: 1, embedId: 'embed-1', hostAnchorId: 'anchor-1' }, + }, + }); + + add$.next([{ unitId: 'doc-1', subUnitId: 'doc-1', drawingId: 'dom-1' }]); + await Promise.resolve(); + + expect(canvasFloatDomService.addFloatDom).toHaveBeenCalledWith(expect.objectContaining({ + eventPassThrough: false, + })); + + controller.dispose(); + }); + + it('falls back to custom block viewport height when refreshes omit transform data', async () => { + const rect = new Rect('dom-rect', { + left: 30, + top: 50, + width: 50, + height: 40, + } as never); + const { controller, add$, refreshTransform$, canvasFloatDomService } = createController({ + rects: [rect], + drawing: { + data: { version: 1, embedId: 'embed-1', hostAnchorId: 'anchor-1' }, + }, + }); + + add$.next([{ unitId: 'doc-1', subUnitId: 'doc-1', drawingId: 'dom-1' }]); + await Promise.resolve(); + + const position$ = canvasFloatDomService.addFloatDom.mock.calls[0][0].position$; + const positions: unknown[] = []; + const sub = position$.subscribe((position: unknown) => positions.push(position)); + + refreshTransform$.next([{ + drawingId: 'dom-1', + customBlockRenderViewport: { contentHeight: 240, height: 240, viewportHeight: 120 }, + }]); + + expect(positions.at(-1)).toMatchObject({ + startX: 40, + startY: 90, + width: 100, + height: 720, + }); + + sub.unsubscribe(); + controller.dispose(); + }); + + it('keeps the measured custom block size while following refreshed doc-flow position', async () => { + const rect = new Rect('dom-rect', { + left: 30, + top: 50, + width: 50, + height: 40, + } as never); + const { controller, add$, refreshTransform$, canvasFloatDomService } = createController({ + rects: [rect], + drawing: { + data: { version: 1, embedId: 'embed-1', hostAnchorId: 'anchor-1' }, + }, + }); + + add$.next([{ unitId: 'doc-1', subUnitId: 'doc-1', drawingId: 'dom-1' }]); + await Promise.resolve(); + + const position$ = canvasFloatDomService.addFloatDom.mock.calls[0][0].position$; + const positions: unknown[] = []; + const sub = position$.subscribe((position: unknown) => positions.push(position)); + + refreshTransform$.next([{ + drawingId: 'dom-1', + transform: { left: 40, top: 60, width: 160, height: 240, angle: 0 }, + customBlockRenderViewport: { contentHeight: 240, height: 240, viewportHeight: 120 }, + }]); + rect.transformByState({ left: 45, top: 65, width: 160, height: 40, angle: 0 } as never); + refreshTransform$.next([{ + drawingId: 'dom-1', + transform: { left: 45, top: 65, width: 160, height: 40, angle: 0 }, + }]); + + expect(positions.at(-1)).toMatchObject({ + startX: 70, + startY: 135, + width: 320, + height: 720, + }); + + sub.unsubscribe(); + controller.dispose(); + }); + + it('keeps embed custom block size while moving slide-like blocks with doc flow refreshes', async () => { + const rect = new Rect('dom-rect', { + left: 30, + top: 240, + width: 720, + height: 405, + } as never); + const { controller, add$, refreshTransform$, canvasFloatDomService } = createController({ + rects: [rect], + drawing: { + data: { version: 1, embedId: 'embed-1', hostAnchorId: 'anchor-1' }, + }, + }); + + add$.next([{ unitId: 'doc-1', subUnitId: 'doc-1', drawingId: 'dom-1' }]); + await Promise.resolve(); + + const position$ = canvasFloatDomService.addFloatDom.mock.calls[0][0].position$; + const positions: unknown[] = []; + const sub = position$.subscribe((position: unknown) => positions.push(position)); + + rect.transformByState({ left: 30, top: 60, width: 720, height: 405, angle: 0 } as never); + refreshTransform$.next([{ + drawingId: 'dom-1', + transform: { left: 30, top: 60, width: 720, height: 405, angle: 0 }, + }]); + + expect(positions.at(-1)).toMatchObject({ + startY: 120, + height: 1215, + }); + + sub.unsubscribe(); + controller.dispose(); + }); + + it('falls back to custom block content height when viewport layout height is omitted', async () => { + const rect = new Rect('dom-rect', { + left: 30, + top: 50, + width: 50, + height: 40, + } as never); + const { controller, add$, canvasFloatDomService } = createController({ + rects: [rect], + drawing: { + customBlockRenderViewport: { contentHeight: 240, viewportHeight: 120 }, + }, + }); + + add$.next([{ unitId: 'doc-1', subUnitId: 'doc-1', drawingId: 'dom-1' }]); + await Promise.resolve(); + + const position$ = canvasFloatDomService.addFloatDom.mock.calls[0][0].position$; + const positions: unknown[] = []; + const sub = position$.subscribe((position: unknown) => positions.push(position)); + + expect(positions.at(-1)).toMatchObject({ + height: 720, + }); + + sub.unsubscribe(); + controller.dispose(); + }); + it('inserts a float dom using page content width when no explicit width is provided', () => { const { controller, commandService } = createController(); diff --git a/packages/docs-drawing-ui/src/controllers/doc-float-dom.controller.spec.ts b/packages/docs-drawing-ui/src/controllers/doc-float-dom.controller.spec.ts new file mode 100644 index 000000000000..5c1cae532f91 --- /dev/null +++ b/packages/docs-drawing-ui/src/controllers/doc-float-dom.controller.spec.ts @@ -0,0 +1,35 @@ +/** + * 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 { describe, expect, it } from 'vitest'; +import { mergeDocFloatDomRuntimeProps } from './doc-float-dom.controller'; + +describe('mergeDocFloatDomRuntimeProps', () => { + it('preserves existing props while adding custom block runtime viewport', () => { + expect(mergeDocFloatDomRuntimeProps({ keep: true }, { + customBlockRenderViewport: { bleedLeft: 96, bleedWidth: 1440, contentHeight: 720, contentWidth: 1280, height: 480, viewportHeight: 320 }, + } as never)).toEqual({ + customBlockRenderViewport: { bleedLeft: 96, bleedWidth: 1440, contentHeight: 720, contentWidth: 1280, height: 480, viewportHeight: 320 }, + keep: true, + }); + }); + + it('keeps existing props when no valid runtime viewport is available', () => { + expect(mergeDocFloatDomRuntimeProps({ keep: true }, { + customBlockRenderViewport: { contentWidth: 0 }, + } as never)).toEqual({ keep: true }); + }); +}); diff --git a/packages/docs-drawing-ui/src/controllers/doc-float-dom.controller.ts b/packages/docs-drawing-ui/src/controllers/doc-float-dom.controller.ts index 1dbf0e660e18..20b2476b04d2 100644 --- a/packages/docs-drawing-ui/src/controllers/doc-float-dom.controller.ts +++ b/packages/docs-drawing-ui/src/controllers/doc-float-dom.controller.ts @@ -14,11 +14,11 @@ * limitations under the License. */ -import type { DocumentDataModel, IDisposable, IDrawingSearch, Nullable } from '@univerjs/core'; +import type { DocumentDataModel, IDisposable, IDrawingSearch, ITransformState, Nullable } from '@univerjs/core'; import type { IDocFloatDom } from '@univerjs/docs-drawing'; import type { ISetDocZoomRatioOperationParams } from '@univerjs/docs-ui'; import type { IDocFloatDomDataBase } from '@univerjs/drawing'; -import type { IBoundRectNoAngle, IRender, Rect, Scene } from '@univerjs/engine-render'; +import type { IBoundRectNoAngle, IDocsCustomBlockRenderViewport, IRender, Rect, Scene } from '@univerjs/engine-render'; import type { IFloatDomLayout } from '@univerjs/ui'; import type { IInsertDrawingCommandParams } from '../commands/commands/interfaces'; import { @@ -86,13 +86,69 @@ function calcDocFloatDomPosition( interface ICanvasFloatDomInfo { position$: BehaviorSubject; dispose: IDisposable; + preserveRuntimeGeometry?: boolean; rect: Rect; + runtimeTransform?: Partial; + runtimeViewport?: IDocFloatDomRuntimeViewport; unitId: string; } interface IDocFloatDomParams extends IDocFloatDomDataBase { } +type IDocFloatDomRuntimeViewport = Partial>; + +interface IDocFloatDomRuntimeParam extends IDocFloatDom { + customBlockRenderViewport?: IDocFloatDomRuntimeViewport; + transform?: Partial; + transforms?: Array>; +} + +export function mergeDocFloatDomRuntimeProps(existingProps: Record | undefined, param: IDocFloatDomRuntimeParam): Record | undefined { + const customBlockRenderViewport = pickValidCustomBlockRenderViewport(param.customBlockRenderViewport); + if (!customBlockRenderViewport) { + return existingProps; + } + + return { + ...existingProps, + customBlockRenderViewport, + }; +} + +function pickValidCustomBlockRenderViewport(viewport: IDocFloatDomRuntimeViewport | undefined): IDocFloatDomRuntimeViewport | undefined { + const result: IDocFloatDomRuntimeViewport = {}; + + if (isNonNegativeNumber(viewport?.bleedLeft)) { + result.bleedLeft = viewport!.bleedLeft; + } + if (isPositiveNumber(viewport?.bleedWidth)) { + result.bleedWidth = viewport!.bleedWidth; + } + if (isPositiveNumber(viewport?.contentHeight)) { + result.contentHeight = viewport!.contentHeight; + } + if (isPositiveNumber(viewport?.contentWidth)) { + result.contentWidth = viewport!.contentWidth; + } + if (isPositiveNumber(viewport?.height)) { + result.height = viewport!.height; + } + if (isPositiveNumber(viewport?.viewportHeight)) { + result.viewportHeight = viewport!.viewportHeight; + } + + return Object.keys(result).length ? result : undefined; +} + +function isPositiveNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value > 0; +} + +function isNonNegativeNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value >= 0; +} + export class DocFloatDomController extends Disposable { private _domLayerInfoMap = new Map(); @@ -115,6 +171,7 @@ export class DocFloatDomController extends Disposable { private _initialize() { this._drawingAddRemoveListener(); + this._drawingRuntimePropsListener(); this._initScrollAndZoomEvent(); } @@ -177,6 +234,11 @@ export class DocFloatDomController extends Disposable { } for (const rect of rects) { + const runtimeParam = rectParam as IDocFloatDomRuntimeParam; + const runtimeViewport = pickValidCustomBlockRenderViewport(runtimeParam.customBlockRenderViewport); + const preserveRuntimeGeometry = isEmbedFloatDomRuntimeParam(runtimeParam); + syncRectWithRuntimeParam(rect, runtimeParam, runtimeViewport, undefined, preserveRuntimeGeometry); + const runtimeTransform = runtimeViewport || preserveRuntimeGeometry ? createTransformFromRect(rect) : undefined; this._addHoverForRect(rect); const disposableCollection = new DisposableCollection(); const initPosition = calcDocFloatDomPosition(rect, renderObject.renderUnit); @@ -186,7 +248,10 @@ export class DocFloatDomController extends Disposable { const info: ICanvasFloatDomInfo = { dispose: disposableCollection, + preserveRuntimeGeometry, rect, + runtimeTransform, + runtimeViewport, position$, unitId, }; @@ -195,6 +260,8 @@ export class DocFloatDomController extends Disposable { position$, id: rectParam.drawingId, componentKey: rectParam.componentKey, + eventPassThrough: preserveRuntimeGeometry ? false : undefined, + preserveOnFocusChange: preserveRuntimeGeometry, onPointerDown: (evt) => { canvas.dispatchEvent(new PointerEvent(evt.type, evt)); }, @@ -208,6 +275,7 @@ export class DocFloatDomController extends Disposable { canvas.dispatchEvent(new WheelEvent(evt.type, evt)); }, data, + props: mergeDocFloatDomRuntimeProps(undefined, rectParam as IDocFloatDomRuntimeParam), unitId, }); @@ -217,16 +285,62 @@ export class DocFloatDomController extends Disposable { newPosition ); }); + const scrollListener = subscribeViewportScrollAfter( + renderObject.scene.getViewport(VIEWPORT_KEY.VIEW_MAIN)?.onScrollAfter$, + () => position$.next(calcDocFloatDomPosition(rect, renderObject.renderUnit)) + ); disposableCollection.add(() => { this._canvasFloatDomService.removeFloatDom(rectParam.drawingId); }); listener && disposableCollection.add(listener); + scrollListener && disposableCollection.add(scrollListener); this._domLayerInfoMap.set(rectParam.drawingId, info); } }); } + private _drawingRuntimePropsListener() { + this.disposeWithMe( + this._drawingManagerService.refreshTransform$.subscribe((params) => { + params.forEach((param) => { + const floatDomInfo = this._domLayerInfoMap.get(param.drawingId); + if (!floatDomInfo) { + return; + } + + const runtimeParam = param as IDocFloatDomRuntimeParam; + const runtimeViewport = pickValidCustomBlockRenderViewport(runtimeParam.customBlockRenderViewport); + if (runtimeViewport) { + floatDomInfo.runtimeViewport = runtimeViewport; + } + + const synced = syncRectWithRuntimeParam( + floatDomInfo.rect, + runtimeParam, + floatDomInfo.runtimeViewport, + floatDomInfo.runtimeTransform, + floatDomInfo.preserveRuntimeGeometry + ); + if (synced) { + if (runtimeViewport || floatDomInfo.preserveRuntimeGeometry) { + floatDomInfo.runtimeTransform = createTransformFromRect(floatDomInfo.rect); + } + const renderObject = this._getSceneAndTransformerByDrawingSearch(floatDomInfo.unitId); + if (renderObject) { + floatDomInfo.position$.next(calcDocFloatDomPosition(floatDomInfo.rect, renderObject.renderUnit)); + } + } + + const currentProps = this._canvasFloatDomService.domLayers.find(([id]) => id === param.drawingId)?.[1].props; + this._canvasFloatDomService.updateFloatDom(param.drawingId, { + props: mergeDocFloatDomRuntimeProps(currentProps, param as IDocFloatDomRuntimeParam), + }); + }); + }) + ); + } + private _addHoverForRect(o: Rect) { this.disposeWithMe( toDisposable( @@ -352,3 +466,101 @@ export class DocFloatDomController extends Disposable { return drawingId; } } + +function syncRectWithRuntimeParam( + rect: Rect, + param: IDocFloatDomRuntimeParam, + fallbackViewport?: IDocFloatDomRuntimeViewport, + fallbackTransform?: Partial, + preserveRuntimeGeometry?: boolean +): boolean { + const transform = getRuntimeTransform(param, rect, fallbackViewport, fallbackTransform, preserveRuntimeGeometry); + if (!transform) { + return false; + } + + rect.transformByState(transform as never); + return true; +} + +function getRuntimeTransform( + param: IDocFloatDomRuntimeParam, + rect: Rect, + fallbackViewport?: IDocFloatDomRuntimeViewport, + fallbackTransform?: Partial, + preserveRuntimeGeometry?: boolean +): Partial | undefined { + const transform = param.transform ?? param.transforms?.[0]; + const runtimeViewport = param.customBlockRenderViewport ?? fallbackViewport; + if (!param.customBlockRenderViewport && preserveRuntimeGeometry && fallbackTransform && transform) { + return { + ...transform, + width: fallbackTransform.width ?? transform.width, + height: fallbackTransform.height ?? transform.height, + }; + } + + if (!transform) { + const height = runtimeViewport?.height ?? runtimeViewport?.contentHeight; + if (!isPositiveNumber(height)) { + return undefined; + } + + return { + left: rect.left, + top: rect.top, + width: rect.width, + height, + angle: rect.angle, + }; + } + + const height = runtimeViewport?.height ?? runtimeViewport?.contentHeight; + if (!isPositiveNumber(height)) { + return transform; + } + + return { + ...transform, + height, + }; +} + +function isEmbedFloatDomRuntimeParam(param: IDocFloatDomRuntimeParam): boolean { + const data = param.data; + if (!data || typeof data !== 'object') { + return false; + } + + const candidate = data as { embedId?: unknown; hostAnchorId?: unknown; version?: unknown }; + return candidate.version === 1 && typeof candidate.embedId === 'string' && typeof candidate.hostAnchorId === 'string'; +} + +function createTransformFromRect(rect: Rect): Partial { + return { + angle: rect.angle, + height: rect.height, + left: rect.left, + top: rect.top, + width: rect.width, + }; +} + +function subscribeViewportScrollAfter(scrollEvent: unknown, callback: () => void): IDisposable | undefined { + if (!scrollEvent || typeof scrollEvent !== 'object') { + return undefined; + } + + const eventSubject = scrollEvent as { subscribeEvent?: (listener: () => void) => IDisposable }; + if (typeof eventSubject.subscribeEvent === 'function') { + return eventSubject.subscribeEvent(callback); + } + + const observable = scrollEvent as { subscribe?: (listener: () => void) => { unsubscribe?: () => void } }; + if (typeof observable.subscribe === 'function') { + const subscription = observable.subscribe(callback); + return toDisposable(() => subscription.unsubscribe?.()); + } + + return undefined; +} diff --git a/packages/docs-drawing-ui/src/controllers/render-controllers/__tests__/doc-drawing-update.render-controller.spec.ts b/packages/docs-drawing-ui/src/controllers/render-controllers/__tests__/doc-drawing-update.render-controller.spec.ts index 9bbbec8b1b71..3a94e6559eb7 100644 --- a/packages/docs-drawing-ui/src/controllers/render-controllers/__tests__/doc-drawing-update.render-controller.spec.ts +++ b/packages/docs-drawing-ui/src/controllers/render-controllers/__tests__/doc-drawing-update.render-controller.spec.ts @@ -15,6 +15,7 @@ */ import { BooleanNumber, FOCUSING_COMMON_DRAWINGS } from '@univerjs/core'; +import { RichTextEditingMutation } from '@univerjs/docs'; import { DocumentEditArea } from '@univerjs/engine-render'; import { Subject } from 'rxjs'; import { describe, expect, it, vi } from 'vitest'; @@ -37,7 +38,7 @@ function createController(options: { const refreshDrawings$ = new Subject(); const onFocus$ = new Subject(); const onBlur$ = new Subject(); - const commandHandlers: Array<(command: { id: string }) => void> = []; + const commandHandlers: Array<(command: { id: string; params?: Record }) => void> = []; let editArea = options.editArea ?? DocumentEditArea.BODY; let isFocusing = options.isFocusing ?? true; @@ -314,4 +315,36 @@ describe('DocDrawingUpdateRenderController', () => { expect(getShape('body-drawing').setOpacity).toHaveBeenLastCalledWith(1); expect(getShape('header-drawing').setOpacity).toHaveBeenLastCalledWith(1); }); + + it('ignores rich text mutations from other document units', async () => { + const { commandHandlers, getShape, scene } = createController({ + drawings: { + 'body-drawing': { + drawingId: 'body-drawing', + isMultiTransform: BooleanNumber.FALSE, + }, + }, + }); + + scene.attachTransformerTo.mockClear(); + getShape('body-drawing').setOpacity.mockClear(); + + commandHandlers.forEach((handler) => handler({ + id: RichTextEditingMutation.id, + params: { unitId: 'other-doc' }, + })); + await Promise.resolve(); + + expect(scene.attachTransformerTo).not.toHaveBeenCalled(); + expect(getShape('body-drawing').setOpacity).not.toHaveBeenCalled(); + + commandHandlers.forEach((handler) => handler({ + id: RichTextEditingMutation.id, + params: { unitId: 'doc-1' }, + })); + await Promise.resolve(); + + expect(scene.attachTransformerTo).toHaveBeenCalledWith(getShape('body-drawing')); + expect(getShape('body-drawing').setOpacity).toHaveBeenCalledWith(1); + }); }); diff --git a/packages/docs-drawing-ui/src/controllers/render-controllers/doc-drawing-transform-update.controller.ts b/packages/docs-drawing-ui/src/controllers/render-controllers/doc-drawing-transform-update.controller.ts index 613db88b610c..c4ae8bec953f 100644 --- a/packages/docs-drawing-ui/src/controllers/render-controllers/doc-drawing-transform-update.controller.ts +++ b/packages/docs-drawing-ui/src/controllers/render-controllers/doc-drawing-transform-update.controller.ts @@ -16,7 +16,7 @@ import type { DocumentDataModel, ICommandInfo, IDrawingParam, ITransformState } from '@univerjs/core'; import type { IRichTextEditingMutationParams } from '@univerjs/docs'; -import type { Documents, DocumentSkeleton, IDocsTableRenderViewport, IDocumentSkeletonHeaderFooter, IDocumentSkeletonPage, IDocumentSkeletonRow, IDocumentSkeletonTable, Image, IRenderContext, IRenderModule } from '@univerjs/engine-render'; +import type { Documents, DocumentSkeleton, IDocsCustomBlockRenderViewport, IDocsTableRenderViewport, IDocumentSkeletonHeaderFooter, IDocumentSkeletonPage, IDocumentSkeletonRow, IDocumentSkeletonTable, Image, IRenderContext, IRenderModule } from '@univerjs/engine-render'; import { AlignTypeH, AlignTypeV, @@ -47,6 +47,7 @@ interface IDrawingParamsWithBehindText { hidden?: boolean; transform: ITransformState; transforms: ITransformState[]; + customBlockRenderViewport?: Partial>; // The same drawing render in different place, like image in header and footer. // The default value is BooleanNumber.FALSE. if it's true, Please use transforms. isMultiTransform: BooleanNumber; @@ -559,6 +560,7 @@ export class DocDrawingTransformUpdateController extends Disposable implements I behindText, transform, transforms: [transform], + customBlockRenderViewport: drawing.customBlockRenderViewport, isMultiTransform, }; } else if (isMultiTransform === BooleanNumber.TRUE) { diff --git a/packages/docs-drawing-ui/src/controllers/render-controllers/doc-drawing-update.render-controller.ts b/packages/docs-drawing-ui/src/controllers/render-controllers/doc-drawing-update.render-controller.ts index b6d669a79c85..0f53141c9a7a 100644 --- a/packages/docs-drawing-ui/src/controllers/render-controllers/doc-drawing-update.render-controller.ts +++ b/packages/docs-drawing-ui/src/controllers/render-controllers/doc-drawing-update.render-controller.ts @@ -15,6 +15,7 @@ */ import type { DocumentDataModel, ICommandInfo, IDocDrawingPosition, IDrawingParam, IImageIoServiceParam, Nullable } from '@univerjs/core'; +import type { IRichTextEditingMutationParams } from '@univerjs/docs'; import type { IDocDrawing } from '@univerjs/docs-drawing'; import type { Documents, Image, IRenderContext, IRenderModule } from '@univerjs/engine-render'; import type { IInsertDrawingCommandParams } from '../../commands/commands/interfaces'; @@ -486,12 +487,18 @@ export class DocDrawingUpdateRenderController extends Disposable implements IRen this.disposeWithMe( this._commandService.onCommandExecuted(async (command: ICommandInfo) => { - if (command.id === RichTextEditingMutation.id) { - // To wait the image is rendered. - queueMicrotask(() => { - this._updateDrawingsEditStatus(); - }); + if (command.id !== RichTextEditingMutation.id) { + return; + } + const params = command.params as Partial | undefined; + if (params?.unitId && params.unitId !== this._context.unitId) { + return; } + + // To wait the image is rendered. + queueMicrotask(() => { + this._updateDrawingsEditStatus(); + }); }) ); } diff --git a/packages/docs-thread-comment-ui/src/menu/__tests__/menu.spec.ts b/packages/docs-thread-comment-ui/src/menu/__tests__/menu.spec.ts deleted file mode 100644 index 1f1ff5bdb5da..000000000000 --- a/packages/docs-thread-comment-ui/src/menu/__tests__/menu.spec.ts +++ /dev/null @@ -1,78 +0,0 @@ -/** - * 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 { FLOAT_TOOLBAR_MENU_POSITION } from '@univerjs/docs-ui'; -import { DocumentEditArea } from '@univerjs/engine-render'; -import { describe, expect, it, vi } from 'vitest'; -import { StartAddCommentOperation } from '../../commands/operations/show-comment-panel.operation'; -import { AddDocCommentMenuItemFactory, shouldDisableAddComment, ToolbarDocCommentMenuItemFactory } from '../menu'; -import { menuSchema } from '../schema'; - -vi.mock('@univerjs/engine-render', async () => { - const actual = await vi.importActual('@univerjs/engine-render'); - return { - ...actual, - withCurrentTypeOfRenderer: vi.fn(() => ({ - getSkeleton: () => ({ - getViewModel: () => ({ - getEditArea: () => DocumentEditArea.BODY, - }), - }), - })), - }; -}); - -vi.mock('@univerjs/ui', async () => { - const actual = await vi.importActual('@univerjs/ui'); - return { - ...actual, - getMenuHiddenObservable: vi.fn(() => null), - }; -}); - -describe('docs-thread-comment-ui menu', () => { - it('shouldDisableAddComment returns true when selection is collapsed', () => { - const accessor = { - get: vi.fn((token) => { - if (token.name === 'IRenderManagerService') return {}; - if (token.name === 'DocSelectionManagerService') { - return { getActiveTextRange: () => ({ collapsed: true }) }; - } - if (token.name === 'IUniverInstanceService') return {}; - return {}; - }), - } as any; - - expect(shouldDisableAddComment(accessor)).toBe(true); - }); - - it('menu factories should return correct ids', () => { - const accessor = { get: vi.fn(() => ({ textSelection$: { pipe: () => ({ subscribe: () => ({ unsubscribe: vi.fn() }) }) } })) } as any; - - const addItem = AddDocCommentMenuItemFactory(accessor); - const toolbarItem = ToolbarDocCommentMenuItemFactory(accessor); - expect(addItem.id).toBe('docs.operation.start-add-comment'); - expect(toolbarItem.id).toBe('docs.operation.toggle-comment-panel'); - }); - - it('adds comment to docs text floating toolbar', () => { - const floatToolbar = (menuSchema as any)[FLOAT_TOOLBAR_MENU_POSITION]; - const comment = floatToolbar[StartAddCommentOperation.id]; - - expect(comment.order).toBe(21); - expect(comment.menuItemFactory).toBe(AddDocCommentMenuItemFactory); - }); -}); diff --git a/packages/docs-ui/src/__tests__/embed-docs-custom-block-bleed.spec.ts b/packages/docs-ui/src/__tests__/embed-docs-custom-block-bleed.spec.ts new file mode 100644 index 000000000000..2645ed97ba98 --- /dev/null +++ b/packages/docs-ui/src/__tests__/embed-docs-custom-block-bleed.spec.ts @@ -0,0 +1,133 @@ +/** + * 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. + */ + +// @vitest-environment jsdom + +import { describe, expect, it, vi } from 'vitest'; +import { + resolveDocsTableLikeCustomBlockBleedViewport, + resolveDocsTableLikeCustomBlockContentHeight, + resolveDocsTableLikeCustomBlockContentWidth, +} from '../embed-docs-custom-block-bleed'; + +describe('resolveDocsTableLikeCustomBlockBleedViewport', () => { + it('keeps the viewport inside the block when content fits the block width', () => { + const boundary = createElementWithRect({ left: 100, right: 1200, width: 1100 }); + const root = createElementWithRect({ left: 220, right: 1180, width: 960 }); + boundary.style.overflow = 'hidden'; + boundary.appendChild(root); + vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1800); + + expect(resolveDocsTableLikeCustomBlockBleedViewport(root, 960)).toEqual({ + bleedLeft: 0, + bleedRight: 0, + bleedWidth: 960, + contentWidth: 960, + virtualWidth: 960, + }); + }); + + it('uses the clipping ancestor as the bleed boundary', () => { + const boundary = createElementWithRect({ left: 100, right: 1200, width: 1100 }); + const root = createElementWithRect({ left: 220, right: 1180, width: 960 }); + boundary.style.overflow = 'hidden'; + boundary.appendChild(root); + vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1800); + + expect(resolveDocsTableLikeCustomBlockBleedViewport(root, 1500)).toEqual({ + bleedLeft: 110, + bleedRight: 10, + bleedWidth: 1080, + contentWidth: 1500, + virtualWidth: 1610, + }); + }); + + it('falls back to the visual window when wide content has no clipping ancestor', () => { + const root = createElementWithRect({ left: 220, right: 1180, width: 960 }); + document.body.appendChild(root); + vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1440); + + expect(resolveDocsTableLikeCustomBlockBleedViewport(root, 1200)).toEqual({ + bleedLeft: 210, + bleedRight: 250, + bleedWidth: 1420, + contentWidth: 1200, + virtualWidth: 1420, + }); + }); + + it('uses authoritative render viewport bleed hints without expanding the layout root', () => { + const root = createElementWithRect({ left: 220, right: 1180, width: 960 }); + document.body.appendChild(root); + vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1440); + + expect(resolveDocsTableLikeCustomBlockBleedViewport(root, 1800, { + bleedLeft: 210, + bleedWidth: 1420, + })).toEqual({ + bleedLeft: 210, + bleedRight: 250, + bleedWidth: 1420, + contentWidth: 1800, + virtualWidth: 2010, + }); + }); + + it('keeps authoritative bleed hints even when runtime content width has not caught up', () => { + const root = createElementWithRect({ left: 220, right: 1180, width: 960 }); + + expect(resolveDocsTableLikeCustomBlockBleedViewport(root, 960, { + bleedLeft: 210, + bleedWidth: 1420, + })).toEqual({ + bleedLeft: 210, + bleedRight: 250, + bleedWidth: 1420, + contentWidth: 960, + virtualWidth: 1420, + }); + }); + + it('prefers authoritative product content width over runtime DOM fallback', () => { + expect(resolveDocsTableLikeCustomBlockContentWidth(1600, 960)).toBe(1600); + expect(resolveDocsTableLikeCustomBlockContentWidth(undefined, 960)).toBe(960); + expect(resolveDocsTableLikeCustomBlockContentWidth(0, 960)).toBe(960); + }); + + it('prefers authoritative product content height over runtime DOM fallback', () => { + expect(resolveDocsTableLikeCustomBlockContentHeight(1200, 480)).toBe(1200); + expect(resolveDocsTableLikeCustomBlockContentHeight(undefined, 480)).toBe(480); + expect(resolveDocsTableLikeCustomBlockContentHeight(0, 480)).toBe(480); + }); +}); + +function createElementWithRect(rect: { left: number; right: number; width: number }): HTMLElement { + const element = document.createElement('div'); + element.getBoundingClientRect = () => ({ + bottom: 0, + height: 0, + left: rect.left, + right: rect.right, + top: 0, + width: rect.width, + x: rect.left, + y: 0, + toJSON: () => rect, + }); + + return element; +} diff --git a/packages/docs-ui/src/__tests__/embed-docs-custom-block-refresh-scroll.spec.ts b/packages/docs-ui/src/__tests__/embed-docs-custom-block-refresh-scroll.spec.ts new file mode 100644 index 000000000000..105ed0f8d89b --- /dev/null +++ b/packages/docs-ui/src/__tests__/embed-docs-custom-block-refresh-scroll.spec.ts @@ -0,0 +1,127 @@ +/** + * 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. + */ + +/** + * @vitest-environment jsdom + */ + +import { UniverInstanceType } from '@univerjs/core'; +import { describe, expect, it, vi } from 'vitest'; +import { + collectDocsTableLikeEmbedChildUnitIds, + createDocsCustomBlockSizeRefreshScheduler, + getCommandUnitId, + shouldRefreshDocsCustomBlockSizeForCommand, +} from '../embed-docs-custom-block-refresh'; +import { scrollDocsTableLikeCustomBlockLive } from '../embed-docs-custom-block-scroll'; + +describe('docs custom block refresh and scroll helpers', () => { + it('collects table-like child units and matches command params', () => { + const childUnitIds = collectDocsTableLikeEmbedChildUnitIds({ + sheet: { data: { childUnitId: 'sheet-1', childType: UniverInstanceType.UNIVER_SHEET } }, + base: { data: { childUnitId: 'base-1', childType: UniverInstanceType.UNIVER_BASE } }, + slide: { data: { childUnitId: 'slide-1', childType: UniverInstanceType.UNIVER_SLIDE } }, + malformed: { data: { childUnitId: 123, childType: UniverInstanceType.UNIVER_SHEET } }, + empty: null, + }); + + expect(Array.from(childUnitIds).sort()).toEqual(['base-1', 'sheet-1']); + expect(getCommandUnitId({ unitId: 'unit-a', unitID: 'unit-b' })).toBe('unit-a'); + expect(getCommandUnitId({ unitID: 'unit-b' })).toBe('unit-b'); + expect(getCommandUnitId(null)).toBeUndefined(); + expect(shouldRefreshDocsCustomBlockSizeForCommand({ + childUnitIds, + commandParams: { unitId: 'sheet-1' }, + hostUnitId: 'doc-1', + })).toBe(true); + expect(shouldRefreshDocsCustomBlockSizeForCommand({ + childUnitIds, + commandParams: { unitId: 'doc-1' }, + hostUnitId: 'doc-1', + })).toBe(false); + expect(shouldRefreshDocsCustomBlockSizeForCommand({ + childUnitIds, + commandParams: { unitId: 'other' }, + hostUnitId: 'doc-1', + })).toBe(false); + }); + + it('coalesces refresh scheduling and cancels pending frames on dispose', () => { + const refresh = vi.fn(); + let callback: (() => void) | undefined; + const frameApi = { + cancelFrame: vi.fn(), + requestFrame: vi.fn((next: () => void) => { + callback = next; + return 7; + }), + }; + const scheduler = createDocsCustomBlockSizeRefreshScheduler(refresh, frameApi); + + scheduler.schedule(); + scheduler.schedule(); + expect(frameApi.requestFrame).toHaveBeenCalledTimes(1); + callback?.(); + expect(refresh).toHaveBeenCalledTimes(1); + + scheduler.schedule(); + scheduler.dispose(); + expect(frameApi.cancelFrame).toHaveBeenCalledWith(7); + scheduler.dispose(); + expect(frameApi.cancelFrame).toHaveBeenCalledTimes(1); + }); + + it('scrolls live block content within horizontal and vertical limits', () => { + const live = createScrollableElement({ + clientHeight: 200, + clientWidth: 300, + scrollHeight: 800, + scrollWidth: 1000, + }); + + expect(scrollDocsTableLikeCustomBlockLive(new WheelEvent('wheel', { deltaX: 500 }), live, { maxScrollLeft: 420 })).toBe(true); + expect(live.scrollLeft).toBe(420); + + expect(scrollDocsTableLikeCustomBlockLive(new WheelEvent('wheel', { deltaY: 900 }), live)).toBe(true); + expect(live.scrollTop).toBe(600); + + expect(scrollDocsTableLikeCustomBlockLive(new WheelEvent('wheel', { deltaY: -50, shiftKey: true }), live)).toBe(true); + expect(live.scrollLeft).toBe(370); + expect(live.scrollTop).toBe(600); + + expect(scrollDocsTableLikeCustomBlockLive(new WheelEvent('wheel', { ctrlKey: true, deltaY: 10 }), live)).toBe(false); + expect(scrollDocsTableLikeCustomBlockLive(new WheelEvent('wheel', { metaKey: true, deltaY: 10 }), live)).toBe(false); + const prevented = new WheelEvent('wheel', { deltaY: 10 }); + prevented.preventDefault(); + expect(scrollDocsTableLikeCustomBlockLive(prevented, live)).toBe(false); + }); +}); + +function createScrollableElement(params: { + clientHeight: number; + clientWidth: number; + scrollHeight: number; + scrollWidth: number; +}): HTMLElement { + const element = document.createElement('div'); + Object.defineProperties(element, { + clientHeight: { configurable: true, value: params.clientHeight }, + clientWidth: { configurable: true, value: params.clientWidth }, + scrollHeight: { configurable: true, value: params.scrollHeight }, + scrollWidth: { configurable: true, value: params.scrollWidth }, + }); + return element; +} diff --git a/packages/docs-ui/src/__tests__/embed-docs-custom-block-refresh.spec.ts b/packages/docs-ui/src/__tests__/embed-docs-custom-block-refresh.spec.ts new file mode 100644 index 000000000000..01f3d1d1ea00 --- /dev/null +++ b/packages/docs-ui/src/__tests__/embed-docs-custom-block-refresh.spec.ts @@ -0,0 +1,111 @@ +/** + * 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 { UniverInstanceType } from '@univerjs/core'; +import { describe, expect, it, vi } from 'vitest'; +import { + collectDocsTableLikeEmbedChildUnitIds, + createDocsCustomBlockSizeRefreshScheduler, + shouldRefreshDocsCustomBlockSizeForCommand, +} from '../embed-docs-custom-block-refresh'; + +describe('docs custom block refresh helpers', () => { + it('collects only sheet-like custom block child unit ids', () => { + expect([...collectDocsTableLikeEmbedChildUnitIds({ + base: { data: { childType: UniverInstanceType.UNIVER_BASE, childUnitId: 'base-1' } }, + doc: { data: { childType: UniverInstanceType.UNIVER_DOC, childUnitId: 'doc-1' } }, + sheet: { data: { childType: UniverInstanceType.UNIVER_SHEET, childUnitId: 'sheet-1' } }, + missing: { data: { childType: UniverInstanceType.UNIVER_SHEET } }, + })].sort()).toEqual(['base-1', 'sheet-1']); + }); + + it('can collect runtime child unit ids through a resolver', () => { + expect([...collectDocsTableLikeEmbedChildUnitIds({ + base: { data: { childType: UniverInstanceType.UNIVER_BASE, embedId: 'base-embed' } }, + sheet: { data: { childType: UniverInstanceType.UNIVER_SHEET, embedId: 'sheet-embed' } }, + slide: { data: { childType: UniverInstanceType.UNIVER_SLIDE, embedId: 'slide-embed' } }, + }, (data) => { + if (data.embedId === 'base-embed') { + return 'base-1'; + } + if (data.embedId === 'sheet-embed') { + return 'sheet-1'; + } + return 'slide-1'; + })].sort()).toEqual(['base-1', 'sheet-1']); + }); + + it('refreshes only when a command targets an embedded child unit', () => { + const childUnitIds = new Set(['sheet-1']); + + expect(shouldRefreshDocsCustomBlockSizeForCommand({ + childUnitIds, + commandParams: { unitId: 'sheet-1' }, + hostUnitId: 'doc-host', + })).toBe(true); + expect(shouldRefreshDocsCustomBlockSizeForCommand({ + childUnitIds, + commandParams: { unitId: 'doc-host' }, + hostUnitId: 'doc-host', + })).toBe(false); + expect(shouldRefreshDocsCustomBlockSizeForCommand({ + childUnitIds, + commandParams: { unitId: 'other' }, + hostUnitId: 'doc-host', + })).toBe(false); + }); + + it('accepts legacy unitID command params when detecting child unit changes', () => { + expect(shouldRefreshDocsCustomBlockSizeForCommand({ + childUnitIds: new Set(['sheet-1']), + commandParams: { unitID: 'sheet-1' }, + hostUnitId: 'doc-host', + })).toBe(true); + }); + + it('coalesces repeated refresh requests into one frame', () => { + const refresh = vi.fn(); + let frameCallback: (() => void) | undefined; + const scheduler = createDocsCustomBlockSizeRefreshScheduler(refresh, { + cancelFrame: vi.fn(), + requestFrame: (callback) => { + frameCallback = callback; + return 1; + }, + }); + + scheduler.schedule(); + scheduler.schedule(); + frameCallback?.(); + + expect(refresh).toHaveBeenCalledTimes(1); + }); + + it('cancels a pending refresh when disposed', () => { + const refresh = vi.fn(); + const cancelFrame = vi.fn(); + const scheduler = createDocsCustomBlockSizeRefreshScheduler(refresh, { + cancelFrame, + requestFrame: () => 7, + }); + + scheduler.schedule(); + scheduler.dispose(); + + expect(cancelFrame).toHaveBeenCalledWith(7); + expect(refresh).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/docs-ui/src/__tests__/embed-docs-custom-block-scroll.spec.ts b/packages/docs-ui/src/__tests__/embed-docs-custom-block-scroll.spec.ts new file mode 100644 index 000000000000..43c080ee7055 --- /dev/null +++ b/packages/docs-ui/src/__tests__/embed-docs-custom-block-scroll.spec.ts @@ -0,0 +1,153 @@ +/** + * 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. + */ + +/** + * @vitest-environment jsdom + */ + +import { describe, expect, it } from 'vitest'; +import { scrollDocsTableLikeCustomBlockLive } from '../embed-docs-custom-block-scroll'; + +describe('scrollDocsTableLikeCustomBlockLive', () => { + it('consumes horizontal wheel while the bleed viewport can scroll', () => { + const live = createScrollableElement({ + clientWidth: 300, + scrollWidth: 900, + }); + + const handled = scrollDocsTableLikeCustomBlockLive(createWheelEvent({ deltaX: 120 }), live); + + expect(handled).toBe(true); + expect(live.scrollLeft).toBe(120); + }); + + it('uses shift vertical wheel as horizontal scroll', () => { + const live = createScrollableElement({ + clientWidth: 300, + scrollWidth: 900, + }); + + const handled = scrollDocsTableLikeCustomBlockLive(createWheelEvent({ deltaY: 80, shiftKey: true }), live); + + expect(handled).toBe(true); + expect(live.scrollLeft).toBe(80); + expect(live.scrollTop).toBe(0); + }); + + it('uses the native horizontal range after the viewport has bled to the left boundary', () => { + const live = createScrollableElement({ + clientWidth: 300, + scrollWidth: 900, + scrollLeft: 250, + }); + + const handled = scrollDocsTableLikeCustomBlockLive(createWheelEvent({ deltaX: 300 }), live, { + maxScrollLeft: undefined, + }); + + expect(handled).toBe(true); + expect(live.scrollLeft).toBe(550); + + const chained = scrollDocsTableLikeCustomBlockLive(createWheelEvent({ deltaX: 10 }), live, { + maxScrollLeft: undefined, + }); + + expect(chained).toBe(true); + expect(live.scrollLeft).toBe(560); + }); + + it('chains horizontal wheel only after the native horizontal range is exhausted', () => { + const live = createScrollableElement({ + clientWidth: 300, + scrollWidth: 900, + scrollLeft: 600, + }); + + const handled = scrollDocsTableLikeCustomBlockLive(createWheelEvent({ deltaX: 10 }), live); + + expect(handled).toBe(false); + expect(live.scrollLeft).toBe(600); + }); + + it('chains vertical wheel to docs when the block cannot scroll further', () => { + const live = createScrollableElement({ + clientHeight: 300, + scrollHeight: 900, + scrollTop: 600, + }); + + const handled = scrollDocsTableLikeCustomBlockLive(createWheelEvent({ deltaY: 120 }), live); + + expect(handled).toBe(false); + expect(live.scrollTop).toBe(600); + }); + + it('consumes vertical wheel while the live container can scroll', () => { + const live = createScrollableElement({ + clientHeight: 300, + scrollHeight: 900, + }); + + const handled = scrollDocsTableLikeCustomBlockLive(createWheelEvent({ deltaY: 120 }), live); + + expect(handled).toBe(true); + expect(live.scrollTop).toBe(120); + }); + + it('does not scroll when the child runtime already consumed the wheel event', () => { + const live = createScrollableElement({ + clientHeight: 300, + scrollHeight: 900, + }); + const event = createWheelEvent({ deltaY: 120 }); + event.preventDefault(); + + const handled = scrollDocsTableLikeCustomBlockLive(event, live); + + expect(handled).toBe(false); + expect(live.scrollTop).toBe(0); + }); +}); + +function createWheelEvent(params: { deltaX?: number; deltaY?: number; shiftKey?: boolean }): WheelEvent { + return new WheelEvent('wheel', { + cancelable: true, + deltaX: params.deltaX ?? 0, + deltaY: params.deltaY ?? 0, + shiftKey: params.shiftKey ?? false, + }); +} + +function createScrollableElement(params: { + clientHeight?: number; + clientWidth?: number; + scrollHeight?: number; + scrollLeft?: number; + scrollTop?: number; + scrollWidth?: number; +}): HTMLElement { + const element = document.createElement('div'); + Object.defineProperties(element, { + clientHeight: { configurable: true, value: params.clientHeight ?? 300 }, + clientWidth: { configurable: true, value: params.clientWidth ?? 300 }, + scrollHeight: { configurable: true, value: params.scrollHeight ?? params.clientHeight ?? 300 }, + scrollWidth: { configurable: true, value: params.scrollWidth ?? params.clientWidth ?? 300 }, + }); + element.scrollLeft = params.scrollLeft ?? 0; + element.scrollTop = params.scrollTop ?? 0; + + return element; +} diff --git a/packages/docs-ui/src/__tests__/embed-host-anchor.spec.ts b/packages/docs-ui/src/__tests__/embed-host-anchor.spec.ts new file mode 100644 index 000000000000..62d25a5fbb7b --- /dev/null +++ b/packages/docs-ui/src/__tests__/embed-host-anchor.spec.ts @@ -0,0 +1,249 @@ +/** + * 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 { DocumentFlavor, PositionedObjectLayoutType, UniverInstanceType } from '@univerjs/core'; +import { + createDocsCustomBlockDrawing, + resolveDocsCustomBlockSize, + shouldUseInlineTextSelectionForDocsCustomBlockDrawing, +} from '@univerjs/docs'; +import { describe, expect, it } from 'vitest'; +import { resolveDocsCustomBlockRenderViewport } from '../embed-host-anchor'; + +describe('resolveDocsCustomBlockSize', () => { + it('uses a taller default size for docs custom blocks', () => { + expect(resolveDocsCustomBlockSize(UniverInstanceType.UNIVER_DOC)).toEqual({ width: 720, height: 360 }); + }); + + it('uses a wider and taller viewport for sheet-like content in docs', () => { + expect(resolveDocsCustomBlockSize(UniverInstanceType.UNIVER_SHEET)).toEqual({ width: 960, height: 480 }); + expect(resolveDocsCustomBlockSize(UniverInstanceType.UNIVER_BASE)).toEqual({ width: 960, height: 480 }); + }); + + it('keeps slide docs blocks at a 16:9 aspect size', () => { + expect(resolveDocsCustomBlockSize(UniverInstanceType.UNIVER_SLIDE)).toEqual({ width: 720, height: 405 }); + }); +}); + +describe('createDocsCustomBlockDrawing', () => { + it('writes the resolved size to both doc transform and drawing transform', () => { + const drawing = createDocsCustomBlockDrawing({ + unitId: 'doc-1', + blockId: 'block-1', + startIndex: 0, + childType: UniverInstanceType.UNIVER_BASE, + }); + + expect(drawing.docTransform?.size).toEqual({ width: 960, height: 480 }); + expect(drawing.transform).toMatchObject({ width: 960, height: 480 }); + }); + + it('disables host transformer and text-selection semantics for block embeds by default', () => { + const drawing = createDocsCustomBlockDrawing({ + unitId: 'doc-1', + blockId: 'block-1', + startIndex: 0, + childType: UniverInstanceType.UNIVER_SHEET, + }); + + expect(drawing.allowTransform).toBe(false); + expect(drawing.layoutType).toBe(PositionedObjectLayoutType.WRAP_TOP_AND_BOTTOM); + expect(shouldUseInlineTextSelectionForDocsCustomBlockDrawing(drawing)).toBe(false); + }); + + it('allows explicit inline embeds to keep text-selection semantics', () => { + const drawing = createDocsCustomBlockDrawing({ + unitId: 'doc-1', + blockId: 'block-1', + startIndex: 0, + interactionMode: 'inline', + }); + + expect(drawing.layoutType).toBe(PositionedObjectLayoutType.INLINE); + expect(shouldUseInlineTextSelectionForDocsCustomBlockDrawing(drawing)).toBe(true); + }); +}); + +describe('resolveDocsCustomBlockRenderViewport', () => { + it('keeps non sheet-like custom blocks at their fallback size', () => { + expect(resolveDocsCustomBlockRenderViewport({ + childType: UniverInstanceType.UNIVER_DOC, + contentHeight: 1200, + documentFlavor: DocumentFlavor.MODERN, + fallbackHeight: 360, + fallbackWidth: 720, + visibleCanvasHeight: 900, + pageMarginLeft: 96, + pageMarginRight: 96, + pageWidth: 1200, + visibleCanvasLeft: 0, + visibleCanvasWidth: 1440, + })).toEqual({ width: 720, height: 360 }); + }); + + it('uses the modern visible canvas viewport for sheet-like docs blocks', () => { + expect(resolveDocsCustomBlockRenderViewport({ + childType: UniverInstanceType.UNIVER_SHEET, + docsLeft: 120, + documentFlavor: DocumentFlavor.MODERN, + fallbackHeight: 480, + fallbackWidth: 960, + pageMarginLeft: 96, + pageMarginRight: 96, + pageWidth: 1200, + scale: 1, + visibleCanvasLeft: 0, + visibleCanvasWidth: 1440, + })).toEqual({ + bleedLeft: 206, + bleedWidth: 1420, + contentHeight: 480, + contentWidth: 960, + height: 480, + layoutWidth: 960, + offsetLeft: 0, + viewportHeight: 480, + width: 960, + }); + }); + + it('uses actual table height for sheet-like docs blocks so docs owns vertical scrolling', () => { + expect(resolveDocsCustomBlockRenderViewport({ + childType: UniverInstanceType.UNIVER_BASE, + contentHeight: 720, + docsLeft: 120, + documentFlavor: DocumentFlavor.MODERN, + fallbackHeight: 480, + fallbackWidth: 960, + pageMarginLeft: 96, + pageMarginRight: 96, + pageWidth: 1200, + scale: 1, + visibleCanvasHeight: 900, + visibleCanvasLeft: 0, + visibleCanvasWidth: 1440, + })).toEqual(expect.objectContaining({ + contentHeight: 720, + height: 720, + viewportHeight: 720, + })); + + expect(resolveDocsCustomBlockRenderViewport({ + childType: UniverInstanceType.UNIVER_BASE, + contentHeight: 1200, + docsLeft: 120, + documentFlavor: DocumentFlavor.MODERN, + fallbackHeight: 480, + fallbackWidth: 960, + pageMarginLeft: 96, + pageMarginRight: 96, + pageWidth: 1200, + scale: 1, + visibleCanvasHeight: 900, + visibleCanvasLeft: 0, + visibleCanvasWidth: 1440, + })).toEqual(expect.objectContaining({ + contentHeight: 1200, + height: 1200, + viewportHeight: 900, + })); + }); + + it('keeps sheet-like layout within page content while the modern docs viewport bleeds', () => { + expect(resolveDocsCustomBlockRenderViewport({ + childType: UniverInstanceType.UNIVER_SHEET, + contentWidth: 1600, + docsLeft: 120, + documentFlavor: DocumentFlavor.MODERN, + fallbackHeight: 480, + fallbackWidth: 960, + pageMarginLeft: 96, + pageMarginRight: 96, + pageWidth: 1200, + scale: 1, + visibleCanvasLeft: 0, + visibleCanvasWidth: 1440, + })).toEqual(expect.objectContaining({ + bleedWidth: 1420, + contentWidth: 1600, + layoutWidth: 1008, + width: 1008, + })); + }); + + it('keeps narrow sheet-like docs blocks at their actual content width', () => { + expect(resolveDocsCustomBlockRenderViewport({ + childType: UniverInstanceType.UNIVER_SHEET, + contentWidth: 420, + docsLeft: 120, + documentFlavor: DocumentFlavor.MODERN, + fallbackHeight: 480, + fallbackWidth: 960, + pageMarginLeft: 96, + pageMarginRight: 96, + pageWidth: 1200, + scale: 1, + visibleCanvasLeft: 0, + visibleCanvasWidth: 1440, + })).toEqual(expect.objectContaining({ + contentWidth: 420, + layoutWidth: 420, + width: 420, + })); + }); + + it('falls back to page content width outside modern docs', () => { + expect(resolveDocsCustomBlockRenderViewport({ + childType: UniverInstanceType.UNIVER_BASE, + contentWidth: 1600, + documentFlavor: DocumentFlavor.TRADITIONAL, + fallbackHeight: 480, + fallbackWidth: 960, + pageMarginLeft: 120, + pageMarginRight: 120, + pageWidth: 840, + })).toEqual({ + contentHeight: 480, + contentWidth: 1600, + height: 480, + layoutWidth: 600, + offsetLeft: 0, + viewportHeight: 480, + width: 600, + }); + }); + + it('keeps narrow sheet-like docs blocks at their actual content width outside modern docs', () => { + expect(resolveDocsCustomBlockRenderViewport({ + childType: UniverInstanceType.UNIVER_BASE, + contentWidth: 420, + documentFlavor: DocumentFlavor.TRADITIONAL, + fallbackHeight: 480, + fallbackWidth: 960, + pageMarginLeft: 120, + pageMarginRight: 120, + pageWidth: 840, + })).toEqual({ + contentHeight: 480, + contentWidth: 420, + height: 480, + layoutWidth: 420, + offsetLeft: 0, + viewportHeight: 480, + width: 420, + }); + }); +}); diff --git a/packages/docs-ui/src/controllers/__tests__/doc-render-controller.spec.ts b/packages/docs-ui/src/controllers/__tests__/doc-render-controller.spec.ts index ad58a6ad1dd9..a54ac956c9bf 100644 --- a/packages/docs-ui/src/controllers/__tests__/doc-render-controller.spec.ts +++ b/packages/docs-ui/src/controllers/__tests__/doc-render-controller.spec.ts @@ -22,6 +22,8 @@ import { describe, expect, it, vi } from 'vitest'; import { DOCS_VIEW_KEY } from '../../basics/docs-view-key'; import { DocRenderController } from '../render-controllers/doc.render-controller'; +const mockScrollBarProps = vi.hoisted(() => [] as unknown[]); + vi.mock('@univerjs/engine-render', async (importOriginal) => { const actual = await importOriginal(); const PageLayoutType = { @@ -81,7 +83,9 @@ vi.mock('@univerjs/engine-render', async (importOriginal) => { }, PageLayoutType, ScrollBar: class MockScrollBar { - constructor(..._args: unknown[]) { } + constructor(...args: unknown[]) { + mockScrollBarProps.push(args[1]); + } }, Viewport: class MockViewport { constructor(..._args: unknown[]) { } @@ -92,10 +96,16 @@ vi.mock('@univerjs/engine-render', async (importOriginal) => { function createControllerFixture(options?: { documentFlavor?: DocumentFlavor; + fitToWidth?: { + align?: 'center' | 'start'; + mode?: 'none' | 'fit-width'; + target?: 'viewport' | 'container'; + }; pendingEditorBackgroundColor?: string | null; pages?: Array>; unitId?: string; }) { + mockScrollBarProps.length = 0; const commandCallbacks: Array<(command: ICommandInfo) => void> = []; const darkMode$ = new Subject(); const canvasElement = { style: {} as Record }; @@ -197,6 +207,13 @@ function createControllerFixture(options?: { }, pageLayoutService, selectionManager, + { + getOptions: vi.fn(() => ({ + mode: options?.fitToWidth?.mode ?? 'none', + target: options?.fitToWidth?.target ?? 'viewport', + align: options?.fitToWidth?.align ?? 'center', + })), + }, { darkMode$ } ); @@ -213,6 +230,28 @@ function createControllerFixture(options?: { } describe('doc render controller', () => { + it('disables only the horizontal scrollbar for container-fitted embedded docs', () => { + createControllerFixture({ + fitToWidth: { + mode: 'fit-width', + target: 'container', + align: 'start', + }, + }); + + expect(mockScrollBarProps[0]).toMatchObject({ + enableHorizontal: false, + }); + }); + + it('keeps the horizontal scrollbar for normal docs', () => { + createControllerFixture(); + + expect(mockScrollBarProps[0]).toMatchObject({ + enableHorizontal: true, + }); + }); + it('refreshes page layout and selection after rich text mutations resize the document', () => { const { commandCallbacks, pageLayoutService, selectionManager } = createControllerFixture(); diff --git a/packages/docs-ui/src/controllers/components.controller.ts b/packages/docs-ui/src/controllers/components.controller.ts index 8e037508bd62..5f61d8d8acb1 100644 --- a/packages/docs-ui/src/controllers/components.controller.ts +++ b/packages/docs-ui/src/controllers/components.controller.ts @@ -100,13 +100,12 @@ import { OrderListTypePicker, } from '../views/list-type-picker/index'; import { PAGE_SETTING_COMPONENT_ID, PageSettings } from '../views/PageSettings'; -import { ParagraphSettingIndex } from '../views/paragraph-setting/index'; import { DOC_PARAGRAPH_MENU_COMPONENT_KEY, DOC_TABLE_BLOCK_MENU_COMPONENT_KEY, - ParagraphMenu, - TableBlockMenu, -} from '../views/ParagraphMenu'; +} from '../views/paragraph-menu/component-keys'; +import { ParagraphSettingIndex } from '../views/paragraph-setting/index'; +import { ParagraphMenu, TableBlockMenu } from '../views/ParagraphMenu'; import { COMPONENT_DOC_CREATE_TABLE_CONFIRM } from '../views/table/create/component-name'; import { DocCreateTableConfirm } from '../views/table/create/TableCreate'; diff --git a/packages/docs-ui/src/controllers/render-controllers/__tests__/doc-input.controller.spec.ts b/packages/docs-ui/src/controllers/render-controllers/__tests__/doc-input.controller.spec.ts new file mode 100644 index 000000000000..1dbfe5d6c6be --- /dev/null +++ b/packages/docs-ui/src/controllers/render-controllers/__tests__/doc-input.controller.spec.ts @@ -0,0 +1,278 @@ +/** + * 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. + */ + +/** + * @vitest-environment jsdom + */ + +import { DOCS_NORMAL_EDITOR_UNIT_ID_KEY } from '@univerjs/core'; +import { EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE, EmbedInteractionBoundaryService, EmbedRuntimeFocusCoordinator } from '../../../services/doc-embed-integration.service'; +import { Subject } from 'rxjs'; +import { describe, expect, it, vi } from 'vitest'; +import { DocInputController } from '../doc-input.controller'; + +describe('DocInputController', () => { + it('does not insert host document text while an embedded child runtime owns focus', async () => { + const onInput$ = new Subject(); + const executeCommand = vi.fn(); + const focusCoordinator = new EmbedRuntimeFocusCoordinator(); + const lease = focusCoordinator.acquireLease({ + embedId: 'embed-sheet', + role: 'child-session', + owner: 'stage2-runtime', + }); + + new DocInputController( + { + unitId: 'host-doc', + unit: { + getSelfOrHeaderFooterModel: vi.fn(() => ({ + getBody: vi.fn(() => ({ dataStream: '\r\n' })), + })), + }, + } as never, + { onInput$ } as never, + { getSkeleton: vi.fn(() => ({})) } as never, + { executeCommand } as never, + { + getDefaultStyle: vi.fn(() => ({})), + getStyleCache: vi.fn(() => ({})), + } as never, + undefined, + focusCoordinator + ); + + onInput$.next({ + event: { defaultPrevented: false, data: '=' }, + content: '=', + activeRange: { + segmentId: undefined, + startOffset: 0, + endOffset: 0, + }, + }); + await Promise.resolve(); + + expect(executeCommand).not.toHaveBeenCalled(); + lease.dispose(); + }); + + it('keeps sheet cell editor input available while an embedded child runtime owns focus', async () => { + const onInput$ = new Subject(); + const executeCommand = vi.fn(); + const focusCoordinator = new EmbedRuntimeFocusCoordinator(); + const lease = focusCoordinator.acquireLease({ + embedId: 'embed-sheet', + role: 'child-session', + owner: 'stage2-runtime', + }); + + new DocInputController( + { + unitId: DOCS_NORMAL_EDITOR_UNIT_ID_KEY, + unit: { + getSelfOrHeaderFooterModel: vi.fn(() => ({ + getBody: vi.fn(() => ({ dataStream: '\r\n' })), + })), + }, + } as never, + { onInput$ } as never, + { getSkeleton: vi.fn(() => ({})) } as never, + { executeCommand } as never, + { + getDefaultStyle: vi.fn(() => ({})), + getStyleCache: vi.fn(() => ({})), + } as never, + undefined, + focusCoordinator + ); + + onInput$.next({ + event: { defaultPrevented: false, data: '=' }, + content: '=', + activeRange: { + segmentId: undefined, + startOffset: 0, + endOffset: 0, + }, + }); + await Promise.resolve(); + + expect(executeCommand).toHaveBeenCalledWith('doc.command.insert-text', expect.objectContaining({ + unitId: DOCS_NORMAL_EDITOR_UNIT_ID_KEY, + })); + lease.dispose(); + }); + + it('does not let a host-scoped child session suppress unrelated host document input', async () => { + const onInput$ = new Subject(); + const executeCommand = vi.fn(); + const focusCoordinator = new EmbedRuntimeFocusCoordinator(); + const lease = focusCoordinator.acquireLease({ + embedId: 'embed-sheet', + role: 'child-session', + owner: 'stage2-runtime', + hostUnitId: 'host-doc', + childUnitId: 'child-sheet', + }); + + new DocInputController( + { + unitId: 'other-host-doc', + unit: { + getSelfOrHeaderFooterModel: vi.fn(() => ({ + getBody: vi.fn(() => ({ dataStream: '\r\n' })), + })), + }, + } as never, + { onInput$ } as never, + { getSkeleton: vi.fn(() => ({})) } as never, + { executeCommand } as never, + { + getDefaultStyle: vi.fn(() => ({})), + getStyleCache: vi.fn(() => ({})), + } as never, + undefined, + focusCoordinator + ); + + onInput$.next({ + event: { defaultPrevented: false, data: 'o' }, + content: 'o', + activeRange: { + segmentId: undefined, + startOffset: 0, + endOffset: 0, + }, + }); + await Promise.resolve(); + + expect(executeCommand).toHaveBeenCalledWith('doc.command.insert-text', expect.objectContaining({ + unitId: 'other-host-doc', + })); + lease.dispose(); + }); + + it('keeps embedded child document input available while its host owns the embed session', async () => { + const onInput$ = new Subject(); + const executeCommand = vi.fn(); + const focusCoordinator = new EmbedRuntimeFocusCoordinator(); + const interactionBoundaryService = new EmbedInteractionBoundaryService(); + const childEditor = document.createElement('div'); + childEditor.setAttribute(EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE, 'embed-doc'); + const childInput = document.createElement('input'); + childEditor.appendChild(childInput); + document.body.appendChild(childEditor); + const lease = focusCoordinator.acquireLease({ + embedId: 'embed-doc', + role: 'child-session', + owner: 'stage2-runtime', + hostUnitId: 'host-sheet', + childUnitId: 'child-doc', + }); + + new DocInputController( + { + unitId: 'child-doc', + unit: { + getSelfOrHeaderFooterModel: vi.fn(() => ({ + getBody: vi.fn(() => ({ dataStream: '\r\n' })), + })), + }, + } as never, + { onInput$ } as never, + { getSkeleton: vi.fn(() => ({})) } as never, + { executeCommand } as never, + { + getDefaultStyle: vi.fn(() => ({})), + getStyleCache: vi.fn(() => ({})), + } as never, + interactionBoundaryService, + focusCoordinator + ); + + onInput$.next({ + event: { defaultPrevented: false, data: 'a', target: childInput }, + content: 'a', + activeRange: { + segmentId: undefined, + startOffset: 0, + endOffset: 0, + }, + }); + await Promise.resolve(); + + expect(executeCommand).toHaveBeenCalledWith('doc.command.insert-text', expect.objectContaining({ + unitId: 'child-doc', + })); + lease.dispose(); + childEditor.remove(); + }); + + it('keeps tab embedded child document input available through runtime ownership without a stage2 lease', async () => { + const onInput$ = new Subject(); + const executeCommand = vi.fn(); + const focusCoordinator = new EmbedRuntimeFocusCoordinator(); + const interactionBoundaryService = new EmbedInteractionBoundaryService(); + const childEditor = document.createElement('div'); + childEditor.setAttribute(EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE, 'sheets-tab-doc'); + const childInput = document.createElement('input'); + childEditor.appendChild(childInput); + document.body.appendChild(childEditor); + const runtimeScope = focusCoordinator.registerRuntimeScope({ + embedId: 'sheets-tab-doc', + hostUnitId: 'host-sheet', + childUnitId: 'child-doc', + }); + + new DocInputController( + { + unitId: 'child-doc', + unit: { + getSelfOrHeaderFooterModel: vi.fn(() => ({ + getBody: vi.fn(() => ({ dataStream: '\r\n' })), + })), + }, + } as never, + { onInput$ } as never, + { getSkeleton: vi.fn(() => ({})) } as never, + { executeCommand } as never, + { + getDefaultStyle: vi.fn(() => ({})), + getStyleCache: vi.fn(() => ({})), + } as never, + interactionBoundaryService, + focusCoordinator + ); + + onInput$.next({ + event: { defaultPrevented: false, data: 'x', target: childInput }, + content: 'x', + activeRange: { + segmentId: undefined, + startOffset: 0, + endOffset: 0, + }, + }); + await Promise.resolve(); + + expect(executeCommand).toHaveBeenCalledWith('doc.command.insert-text', expect.objectContaining({ + unitId: 'child-doc', + })); + runtimeScope.dispose(); + childEditor.remove(); + }); +}); 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 a1095263825b..101908ec1a9a 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 @@ -14,6 +14,11 @@ * limitations under the License. */ +// @vitest-environment jsdom + +import type { EmbedInteractionBoundaryService } from '../../../services/doc-embed-integration.service'; +import { DOCS_NORMAL_EDITOR_UNIT_ID_KEY } from '@univerjs/core'; +import { EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE, EmbedRuntimeFocusCoordinator } from '../../../services/doc-embed-integration.service'; import { CURSOR_TYPE, DocumentEditArea } from '@univerjs/engine-render'; import { Subject } from 'rxjs'; import { afterEach, describe, expect, it, vi } from 'vitest'; @@ -53,7 +58,7 @@ function createEventSubject() { }; } -function createController(options: { readonly?: boolean; hasEditor?: boolean } = {}) { +function createController(options: { readonly?: boolean; hasEditor?: boolean; embedRecentInteraction?: boolean; embedContains?: boolean; unitId?: string; currentSelectionUnitId?: string; embedRuntimeFocusCoordinator?: EmbedRuntimeFocusCoordinator } = {}) { const refreshSelection$ = new Subject(); const textSelectionInner$ = new Subject(); const currentSkeleton$ = new Subject(); @@ -107,7 +112,7 @@ function createController(options: { readonly?: boolean; hasEditor?: boolean } = const docSelectionManagerService = { refreshSelection$, __replaceTextRangesWithNoRefresh: vi.fn(), - __getCurrentSelection: vi.fn(() => ({ unitId: 'doc-1' })), + __getCurrentSelection: vi.fn(() => ({ unitId: options.currentSelectionUnitId ?? options.unitId ?? 'doc-1' })), refreshSelection: vi.fn(), replaceDocRanges: vi.fn(), }; @@ -119,9 +124,19 @@ function createController(options: { readonly?: boolean; hasEditor?: boolean } = focus: vi.fn(), getFocusId: vi.fn(() => null), }; + const embedInteractionBoundaryService = { + contains: vi.fn(() => options.embedContains ?? false), + hasRecentInteraction: vi.fn(() => options.embedRecentInteraction ?? false), + hasRecentInteractionFor: vi.fn(() => options.embedRecentInteraction ?? false), + }; + const instanceService = { + getCurrentUnitOfType: vi.fn(() => ({ getUnitId: () => 'other-doc' })), + setCurrentUnitForType: vi.fn(), + focusUnit: vi.fn(), + }; const controller = new DocSelectionRenderController( { - unitId: 'doc-1', + unitId: options.unitId ?? 'doc-1', unit: { getSnapshot: vi.fn(() => ({ body: { dataStream: 'abc\r\n' } })) }, } as never, { @@ -131,17 +146,16 @@ function createController(options: { readonly?: boolean; hasEditor?: boolean } = }), } as never, editorService as never, - { - getCurrentUnitOfType: vi.fn(() => ({ getUnitId: () => 'other-doc' })), - setCurrentUnitForType: vi.fn(), - } as never, + instanceService as never, docSelectionRenderService as never, { getSkeleton: vi.fn(() => skeleton), getViewModel: vi.fn(() => viewModel), currentSkeleton$, } as never, - docSelectionManagerService as never + docSelectionManagerService as never, + embedInteractionBoundaryService as unknown as EmbedInteractionBoundaryService, + options.embedRuntimeFocusCoordinator as never ); return { @@ -157,6 +171,8 @@ function createController(options: { readonly?: boolean; hasEditor?: boolean } = docSelectionRenderService, docSelectionManagerService, editorService, + instanceService, + embedInteractionBoundaryService, }; } @@ -204,6 +220,163 @@ describe('DocSelectionRenderController', () => { controller.dispose(); }); + it('does not refresh host document selection while a child session owns focus during zoom refreshes', () => { + const focusCoordinator = new EmbedRuntimeFocusCoordinator(); + const lease = focusCoordinator.acquireLease({ + embedId: 'embed-1', + role: 'child-session', + owner: 'stage2-runtime', + hostUnitId: 'doc-1', + }); + const { controller, commandHandlers, docSelectionManagerService } = createController({ + embedRuntimeFocusCoordinator: focusCoordinator, + }); + + commandHandlers[0]({ id: SetDocZoomRatioOperation.id, params: { unitId: 'doc-1' } }); + + expect(docSelectionManagerService.refreshSelection).not.toHaveBeenCalled(); + + controller.dispose(); + lease.dispose(); + }); + + it('does not refresh host document selection while an embedded child editor owns focus', () => { + const focusCoordinator = new EmbedRuntimeFocusCoordinator(); + const lease = focusCoordinator.acquireLease({ + embedId: 'embed-1', + role: 'child-editor', + owner: 'sheet-cell-editor', + }); + const { controller, commandHandlers, docSelectionManagerService } = createController({ + embedRecentInteraction: false, + embedRuntimeFocusCoordinator: focusCoordinator, + }); + + commandHandlers[0]({ id: SetDocZoomRatioOperation.id, params: { unitId: 'doc-1' } }); + + expect(docSelectionManagerService.refreshSelection).not.toHaveBeenCalled(); + + lease.dispose(); + controller.dispose(); + }); + + it('does not refresh host document selection while an embedded child session owns interaction', () => { + const focusCoordinator = new EmbedRuntimeFocusCoordinator(); + const lease = focusCoordinator.acquireLease({ + embedId: 'embed-1', + role: 'child-session', + owner: 'stage2-runtime', + }); + const { controller, commandHandlers, docSelectionManagerService } = createController({ + embedRecentInteraction: false, + embedRuntimeFocusCoordinator: focusCoordinator, + }); + + commandHandlers[0]({ id: SetDocZoomRatioOperation.id, params: { unitId: 'doc-1' } }); + + expect(docSelectionManagerService.refreshSelection).not.toHaveBeenCalled(); + + lease.dispose(); + controller.dispose(); + }); + + it('does not sync host document inner selections while an embedded child session owns interaction', () => { + const focusCoordinator = new EmbedRuntimeFocusCoordinator(); + const lease = focusCoordinator.acquireLease({ + embedId: 'embed-1', + role: 'child-session', + owner: 'doc-block-stage2-runtime', + hostUnitId: 'doc-1', + childUnitId: 'child-base', + }); + const { controller, textSelectionInner$, docSelectionManagerService } = createController({ + embedRuntimeFocusCoordinator: focusCoordinator, + }); + + textSelectionInner$.next([{ startOffset: 1, endOffset: 1 }]); + + expect(docSelectionManagerService.__replaceTextRangesWithNoRefresh).not.toHaveBeenCalled(); + + lease.dispose(); + controller.dispose(); + }); + + it('still syncs embedded internal editor selections while a child session owns interaction', () => { + const focusCoordinator = new EmbedRuntimeFocusCoordinator(); + const lease = focusCoordinator.acquireLease({ + embedId: 'embed-1', + role: 'child-session', + owner: 'doc-block-stage2-runtime', + hostUnitId: 'doc-1', + childUnitId: 'child-sheet', + }); + const { controller, textSelectionInner$, docSelectionManagerService } = createController({ + unitId: DOCS_NORMAL_EDITOR_UNIT_ID_KEY, + embedRuntimeFocusCoordinator: focusCoordinator, + }); + + textSelectionInner$.next([{ startOffset: 1, endOffset: 3 }]); + + expect(docSelectionManagerService.__replaceTextRangesWithNoRefresh).toHaveBeenCalledWith( + [{ startOffset: 1, endOffset: 3 }], + { unitId: DOCS_NORMAL_EDITOR_UNIT_ID_KEY, subUnitId: DOCS_NORMAL_EDITOR_UNIT_ID_KEY } + ); + + lease.dispose(); + controller.dispose(); + }); + + it('does not initialize host hidden editor selection when a child session owns the host document', () => { + const focusCoordinator = new EmbedRuntimeFocusCoordinator(); + const lease = focusCoordinator.acquireLease({ + embedId: 'embed-1', + role: 'child-session', + owner: 'stage2-runtime', + hostUnitId: 'doc-1', + childUnitId: 'child-sheet', + }); + const { + controller, + currentSkeleton$, + docSelectionRenderService, + docSelectionManagerService, + } = createController({ + embedRuntimeFocusCoordinator: focusCoordinator, + }); + + currentSkeleton$.next({ id: 'skeleton' }); + + expect(docSelectionRenderService.focus).not.toHaveBeenCalled(); + expect(docSelectionManagerService.replaceDocRanges).not.toHaveBeenCalled(); + + lease.dispose(); + controller.dispose(); + }); + + it('does not let a host-scoped child session suppress unrelated host document selection refreshes', () => { + const focusCoordinator = new EmbedRuntimeFocusCoordinator(); + const lease = focusCoordinator.acquireLease({ + embedId: 'embed-1', + role: 'child-session', + owner: 'stage2-runtime', + hostUnitId: 'host-doc', + childUnitId: 'child-sheet', + }); + const { controller, commandHandlers, docSelectionManagerService } = createController({ + unitId: 'other-host-doc', + currentSelectionUnitId: 'other-host-doc', + embedRecentInteraction: false, + embedRuntimeFocusCoordinator: focusCoordinator, + }); + + commandHandlers[0]({ id: SetDocZoomRatioOperation.id, params: { unitId: 'other-host-doc' } }); + + expect(docSelectionManagerService.refreshSelection).toHaveBeenCalledTimes(1); + + lease.dispose(); + controller.dispose(); + }); + it('maps document pointer gestures to selection rendering and editor focus', () => { vi.useFakeTimers(); const { @@ -213,6 +386,7 @@ describe('DocSelectionRenderController', () => { viewModel, docSelectionRenderService, editorService, + instanceService, } = createController({ hasEditor: true }); const stopPropagation = vi.fn(); @@ -230,10 +404,206 @@ describe('DocSelectionRenderController', () => { expect(viewModel.setEditArea).toHaveBeenCalledWith(DocumentEditArea.HEADER); expect(docSelectionRenderService.__onPointDown).toHaveBeenCalled(); expect(editorService.focus).toHaveBeenCalledWith('doc-1'); + expect(instanceService.focusUnit).toHaveBeenCalledWith('doc-1'); expect(stopPropagation).toHaveBeenCalled(); expect(docSelectionRenderService.__handleDblClick).toHaveBeenCalled(); expect(docSelectionRenderService.__handleTripleClick).toHaveBeenCalled(); controller.dispose(); }); + + it('ignores pointer gestures that originate inside an embed interaction boundary', () => { + const { + controller, + document, + docSelectionRenderService, + } = createController({ hasEditor: true }); + const stopPropagation = vi.fn(); + const embedTarget = window.document.createElement('div'); + embedTarget.setAttribute(EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE, 'embed-1'); + + document.onPointerDown$.emit({ offsetX: 11, offsetY: 22, button: 0, target: embedTarget }, { stopPropagation }); + document.onDblclick$.emit({ offsetX: 11, offsetY: 22, target: embedTarget }); + document.onTripleClick$.emit({ offsetX: 11, offsetY: 22, target: embedTarget }); + + expect(docSelectionRenderService.__onPointDown).not.toHaveBeenCalled(); + expect(docSelectionRenderService.__handleDblClick).not.toHaveBeenCalled(); + expect(docSelectionRenderService.__handleTripleClick).not.toHaveBeenCalled(); + expect(stopPropagation).not.toHaveBeenCalled(); + + controller.dispose(); + }); + + it('ignores host canvas pointer gestures while a child runtime session owns the host document', () => { + const focusCoordinator = new EmbedRuntimeFocusCoordinator(); + const lease = focusCoordinator.acquireLease({ + embedId: 'embed-1', + role: 'child-session', + owner: 'stage2-runtime', + hostUnitId: 'doc-1', + childUnitId: 'child-sheet', + }); + const { + controller, + document, + docSelectionRenderService, + instanceService, + } = createController({ + hasEditor: true, + embedRuntimeFocusCoordinator: focusCoordinator, + }); + const stopPropagation = vi.fn(); + const hostCanvas = window.document.createElement('canvas'); + + document.onPointerDown$.emit({ offsetX: 11, offsetY: 22, button: 0, target: hostCanvas }, { stopPropagation }); + document.onDblclick$.emit({ offsetX: 11, offsetY: 22, target: hostCanvas }); + document.onTripleClick$.emit({ offsetX: 11, offsetY: 22, target: hostCanvas }); + + expect(docSelectionRenderService.__onPointDown).not.toHaveBeenCalled(); + expect(docSelectionRenderService.__handleDblClick).not.toHaveBeenCalled(); + expect(docSelectionRenderService.__handleTripleClick).not.toHaveBeenCalled(); + expect(instanceService.focusUnit).not.toHaveBeenCalled(); + expect(stopPropagation).not.toHaveBeenCalled(); + + lease.dispose(); + controller.dispose(); + }); + + it('keeps embedded internal editors interactive inside their own embed boundary', () => { + const { + controller, + document, + docSelectionRenderService, + } = createController({ hasEditor: true, unitId: '__INTERNAL_EDITOR__DOCS_NORMAL' }); + const stopPropagation = vi.fn(); + const embedTarget = window.document.createElement('canvas'); + embedTarget.setAttribute(EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE, 'embed-1'); + + document.onPointerDown$.emit({ offsetX: 11, offsetY: 22, button: 0, target: embedTarget }, { stopPropagation }); + document.onDblclick$.emit({ offsetX: 11, offsetY: 22, target: embedTarget }); + + expect(docSelectionRenderService.__onPointDown).toHaveBeenCalled(); + expect(docSelectionRenderService.__handleDblClick).toHaveBeenCalled(); + expect(stopPropagation).toHaveBeenCalled(); + + controller.dispose(); + }); + + it('ignores pointer gestures when the event target is host canvas but the screen point is inside an embed boundary', () => { + const { + controller, + document, + docSelectionRenderService, + } = createController({ hasEditor: true }); + const stopPropagation = vi.fn(); + const hostCanvas = window.document.createElement('canvas'); + const embedTarget = window.document.createElement('div'); + embedTarget.setAttribute(EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE, 'embed-1'); + const previousElementFromPoint = window.document.elementFromPoint; + Object.defineProperty(window.document, 'elementFromPoint', { + configurable: true, + value: vi.fn(() => embedTarget), + }); + + document.onPointerDown$.emit({ offsetX: 11, offsetY: 22, clientX: 100, clientY: 200, button: 0, target: hostCanvas }, { stopPropagation }); + + expect(docSelectionRenderService.__onPointDown).not.toHaveBeenCalled(); + expect(stopPropagation).not.toHaveBeenCalled(); + + Object.defineProperty(window.document, 'elementFromPoint', { + configurable: true, + value: previousElementFromPoint, + }); + controller.dispose(); + }); + + it('uses target canvas bounds and offset coordinates to detect embed boundary gestures', () => { + const { + controller, + document, + docSelectionRenderService, + } = createController({ hasEditor: true }); + const stopPropagation = vi.fn(); + const hostCanvas = window.document.createElement('canvas'); + hostCanvas.getBoundingClientRect = () => ({ + bottom: 900, + height: 800, + left: 50, + right: 1250, + top: 100, + width: 1200, + x: 50, + y: 100, + toJSON: () => ({}), + } as DOMRect); + const embedTarget = window.document.createElement('div'); + embedTarget.setAttribute(EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE, 'embed-1'); + const previousElementFromPoint = window.document.elementFromPoint; + Object.defineProperty(window.document, 'elementFromPoint', { + configurable: true, + value: vi.fn((x: number, y: number) => x === 150 && y === 320 ? embedTarget : null), + }); + + document.onPointerDown$.emit({ offsetX: 100, offsetY: 220, button: 0, target: hostCanvas } as never, { stopPropagation }); + + expect(docSelectionRenderService.__onPointDown).not.toHaveBeenCalled(); + expect(stopPropagation).not.toHaveBeenCalled(); + + Object.defineProperty(window.document, 'elementFromPoint', { + configurable: true, + value: previousElementFromPoint, + }); + controller.dispose(); + }); + + it('suppresses host-canvas gestures while a child session owns focus', () => { + const focusCoordinator = new EmbedRuntimeFocusCoordinator(); + const lease = focusCoordinator.acquireLease({ + embedId: 'embed-1', + role: 'child-session', + owner: 'stage2-runtime', + hostUnitId: 'doc-1', + }); + const { + controller, + document, + docSelectionRenderService, + embedInteractionBoundaryService, + } = createController({ hasEditor: true, embedRuntimeFocusCoordinator: focusCoordinator }); + const stopPropagation = vi.fn(); + const hostCanvas = window.document.createElement('canvas'); + const previousElementFromPoint = window.document.elementFromPoint; + Object.defineProperty(window.document, 'elementFromPoint', { + configurable: true, + value: vi.fn(() => null), + }); + + document.onPointerDown$.emit({ + offsetX: 100, + offsetY: 220, + clientX: 150, + clientY: 320, + button: 0, + target: hostCanvas, + } as never, { stopPropagation }); + document.onDblclick$.emit({ + offsetX: 100, + offsetY: 220, + clientX: 150, + clientY: 320, + target: hostCanvas, + } as never); + + expect(embedInteractionBoundaryService.contains).not.toHaveBeenCalledWith(undefined, hostCanvas, expect.anything()); + expect(docSelectionRenderService.__onPointDown).not.toHaveBeenCalled(); + expect(docSelectionRenderService.__handleDblClick).not.toHaveBeenCalled(); + expect(stopPropagation).not.toHaveBeenCalled(); + + Object.defineProperty(window.document, 'elementFromPoint', { + configurable: true, + value: previousElementFromPoint, + }); + controller.dispose(); + lease.dispose(); + }); }); diff --git a/packages/docs-ui/src/controllers/render-controllers/__tests__/zoom.render-controller.spec.ts b/packages/docs-ui/src/controllers/render-controllers/__tests__/zoom.render-controller.spec.ts index f89c108f60bd..572c1af524c9 100644 --- a/packages/docs-ui/src/controllers/render-controllers/__tests__/zoom.render-controller.spec.ts +++ b/packages/docs-ui/src/controllers/render-controllers/__tests__/zoom.render-controller.spec.ts @@ -53,10 +53,19 @@ describe('DocZoomRenderController', () => { expect(shouldHandleDocWheelZoom({ ctrlKey: true, metaKey: false }, false, DocumentFlavor.TRADITIONAL)).toBe(false); }); - it('applies composed view scale while receiving user zoom', () => { + it('applies composed view scale and immediately renders embedded docs while receiving user zoom', () => { const controller = Object.create(DocZoomRenderController.prototype) as DocZoomRenderController; + const refreshSelection = vi.fn(); + const makeDirty = vi.fn(); + const render = vi.fn(); Object.assign(controller, { - _context: { unitId: 'doc-unit' }, + _context: { + unitId: 'doc-unit', + scene: { + makeDirty, + render, + }, + }, _docViewScaleService: { getViewScale: vi.fn(() => 1.875), }, @@ -67,7 +76,13 @@ describe('DocZoomRenderController', () => { calculatePagePosition: vi.fn(), }, _textSelectionManagerService: { - refreshSelection: vi.fn(), + refreshSelection, + }, + _embedInteractionBoundaryService: { + hasRecentInteraction: vi.fn(() => false), + }, + _univerInstanceService: { + getUnitCreateOptions: vi.fn(() => ({ embeddedRender: true, skipAutoRender: true })), }, }); @@ -75,5 +90,83 @@ describe('DocZoomRenderController', () => { expect((controller as never as { _docViewScaleService: { getViewScale: ReturnType } })._docViewScaleService.getViewScale).toHaveBeenCalledWith(1.25); expect(mockSceneScale).toHaveBeenCalledWith(1.875, 1.875); + expect(makeDirty).toHaveBeenCalled(); + expect(render).toHaveBeenCalled(); + expect(refreshSelection).toHaveBeenCalled(); + }); + + it('does not force an immediate render for standalone docs', () => { + const controller = Object.create(DocZoomRenderController.prototype) as DocZoomRenderController; + const makeDirty = vi.fn(); + const render = vi.fn(); + Object.assign(controller, { + _context: { + unitId: 'doc-unit', + scene: { + makeDirty, + render, + }, + }, + _docViewScaleService: { + getViewScale: vi.fn(() => 1.875), + }, + _editorService: { + isEditor: vi.fn(() => false), + }, + _docPageLayoutService: { + calculatePagePosition: vi.fn(), + }, + _textSelectionManagerService: { + refreshSelection: vi.fn(), + }, + _embedInteractionBoundaryService: { + hasRecentInteraction: vi.fn(() => false), + }, + _univerInstanceService: { + getUnitCreateOptions: vi.fn(() => undefined), + }, + }); + + controller.updateViewZoom(1.25); + + expect(mockSceneScale).toHaveBeenCalledWith(1.875, 1.875); + expect(makeDirty).not.toHaveBeenCalled(); + expect(render).not.toHaveBeenCalled(); + }); + + it('does not refresh host document selection while embed interaction is active', () => { + const controller = Object.create(DocZoomRenderController.prototype) as DocZoomRenderController; + const refreshSelection = vi.fn(); + Object.assign(controller, { + _context: { + unitId: 'doc-unit', + scene: { + makeDirty: vi.fn(), + render: vi.fn(), + }, + }, + _docViewScaleService: { + getViewScale: vi.fn(() => 1.875), + }, + _editorService: { + isEditor: vi.fn(() => false), + }, + _docPageLayoutService: { + calculatePagePosition: vi.fn(), + }, + _textSelectionManagerService: { + refreshSelection, + }, + _embedInteractionBoundaryService: { + hasRecentInteraction: vi.fn(() => true), + }, + _univerInstanceService: { + getUnitCreateOptions: vi.fn(() => ({ embeddedRender: true })), + }, + }); + + controller.updateViewZoom(1.25); + + expect(refreshSelection).not.toHaveBeenCalled(); }); }); diff --git a/packages/docs-ui/src/controllers/render-controllers/doc-input.controller.ts b/packages/docs-ui/src/controllers/render-controllers/doc-input.controller.ts index e34fe1d09bfc..213a52866d56 100644 --- a/packages/docs-ui/src/controllers/render-controllers/doc-input.controller.ts +++ b/packages/docs-ui/src/controllers/render-controllers/doc-input.controller.ts @@ -18,11 +18,12 @@ import type { DocumentDataModel, Nullable } from '@univerjs/core'; import type { IInsertTextCommandParams } from '@univerjs/docs'; import type { IRenderContext, IRenderModule } from '@univerjs/engine-render'; import type { Subscription } from 'rxjs'; -import { Disposable, ICommandService, Inject, SHEET_EDITOR_UNITS } from '@univerjs/core'; +import { Disposable, ICommandService, Inject, Optional, SHEET_EDITOR_UNITS } from '@univerjs/core'; import { DocSkeletonManagerService, InsertTextCommand } from '@univerjs/docs'; import { getCustomDecorationAtPosition, getCustomRangeAtPosition, getTextRunAtPosition } from '../../basics/paragraph'; import { AfterSpaceCommand } from '../../commands/commands/auto-format.command'; import { DocMenuStyleService } from '../../services/doc-menu-style.service'; +import { IDocEmbedInteractionBoundaryService, IDocEmbedRuntimeFocusCoordinator } from '../../services/doc-embed-integration.service'; import { DocSelectionRenderService } from '../../services/selection/doc-selection-render.service'; export class DocInputController extends Disposable implements IRenderModule { @@ -33,7 +34,9 @@ export class DocInputController extends Disposable implements IRenderModule { @Inject(DocSelectionRenderService) private readonly _docSelectionRenderService: DocSelectionRenderService, @Inject(DocSkeletonManagerService) private readonly _docSkeletonManagerService: DocSkeletonManagerService, @ICommandService private readonly _commandService: ICommandService, - @Inject(DocMenuStyleService) private readonly _docMenuStyleService: DocMenuStyleService + @Inject(DocMenuStyleService) private readonly _docMenuStyleService: DocMenuStyleService, + @Optional(IDocEmbedInteractionBoundaryService) _embedInteractionBoundaryService?: IDocEmbedInteractionBoundaryService, + @Optional(IDocEmbedRuntimeFocusCoordinator) private readonly _embedRuntimeFocusCoordinator?: IDocEmbedRuntimeFocusCoordinator ) { super(); @@ -65,6 +68,10 @@ export class DocInputController extends Disposable implements IRenderModule { return; } + if (!SHEET_EDITOR_UNITS.includes(unitId) && this._isEmbedChildInputActive(unitId, e)) { + return; + } + const skeleton = this._docSkeletonManagerService.getSkeleton(); if (e.data == null || skeleton == null || activeRange == null) { @@ -122,4 +129,20 @@ export class DocInputController extends Disposable implements IRenderModule { } }); } + + private _isEmbedChildInputActive(unitId: string, event: Event): boolean { + if (this._embedRuntimeFocusCoordinator?.isChildUnitRuntimeEvent(unitId, event.target, event)) { + return false; + } + + if (this._embedRuntimeFocusCoordinator?.isChildUnitInActiveSession(unitId)) { + return false; + } + + if (this._embedRuntimeFocusCoordinator?.shouldSuppressHostInteraction(unitId, event.target, event)) { + return true; + } + + return false; + } } 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 a93b399bffb5..040d10d312b1 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 @@ -29,6 +29,7 @@ import { Inject, isInternalEditorID, IUniverInstanceService, + Optional, UniverInstanceType, } from '@univerjs/core'; import { DocSelectionManagerService, DocSkeletonManagerService } from '@univerjs/docs'; @@ -36,6 +37,7 @@ import { CURSOR_TYPE, DocumentEditArea, PageLayoutType, Vector2 } from '@univerj import { neoGetDocObject } from '../../basics/component-tools'; import { findFirstCursorOffset } from '../../basics/selection'; import { SetDocZoomRatioOperation } from '../../commands/operations/set-doc-zoom-ratio.operation'; +import { DOC_EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE, IDocEmbedInteractionBoundaryService, IDocEmbedRuntimeFocusCoordinator } from '../../services/doc-embed-integration.service'; import { IEditorService } from '../../services/editor/editor-manager.service'; import { DocSelectionRenderService } from '../../services/selection/doc-selection-render.service'; @@ -49,7 +51,9 @@ export class DocSelectionRenderController extends Disposable implements IRenderM @IUniverInstanceService private readonly _instanceSrv: IUniverInstanceService, @Inject(DocSelectionRenderService) private readonly _docSelectionRenderService: DocSelectionRenderService, @Inject(DocSkeletonManagerService) private readonly _docSkeletonManagerService: DocSkeletonManagerService, - @Inject(DocSelectionManagerService) private readonly _docSelectionManagerService: DocSelectionManagerService + @Inject(DocSelectionManagerService) private readonly _docSelectionManagerService: DocSelectionManagerService, + @Optional(IDocEmbedInteractionBoundaryService) _embedInteractionBoundaryService?: IDocEmbedInteractionBoundaryService, + @Optional(IDocEmbedRuntimeFocusCoordinator) private readonly _embedRuntimeFocusCoordinator?: IDocEmbedRuntimeFocusCoordinator ) { super(); @@ -103,6 +107,10 @@ export class DocSelectionRenderController extends Disposable implements IRenderM return; } + if (!isInternalEditorID(this._context.unitId) && this._isEmbedChildInteractionActive(this._context.unitId)) { + return; + } + this._docSelectionManagerService.__replaceTextRangesWithNoRefresh(params, { unitId: this._context.unitId, subUnitId: this._context.unitId, @@ -131,12 +139,16 @@ export class DocSelectionRenderController extends Disposable implements IRenderM if (this._isEditorReadOnly(unitId)) { return; } + if (this._isEmbedInteractionEvent(evt, unitId)) { + return; + } // FIXME:@Jocs: editor status should not be coupled with the instance service. const docDataModel = this._instanceSrv.getCurrentUnitOfType(UniverInstanceType.UNIVER_DOC); if (docDataModel?.getUnitId() !== unitId) { this._instanceSrv.setCurrentUnitForType(unitId); } + this._instanceSrv.focusUnit(unitId); const skeleton = this._docSkeletonManagerService.getSkeleton(); const { offsetX, offsetY } = evt; @@ -195,6 +207,9 @@ export class DocSelectionRenderController extends Disposable implements IRenderM if (this._isEditorReadOnly(unitId)) { return; } + if (this._isEmbedInteractionEvent(evt, unitId)) { + return; + } this._docSelectionRenderService.__handleDblClick(evt); })); @@ -203,6 +218,9 @@ export class DocSelectionRenderController extends Disposable implements IRenderM if (this._isEditorReadOnly(unitId)) { return; } + if (this._isEmbedInteractionEvent(evt, unitId)) { + return; + } this._docSelectionRenderService.__handleTripleClick(evt); })); @@ -236,6 +254,31 @@ export class DocSelectionRenderController extends Disposable implements IRenderM this._editorService.focus(unitId); } + private _isEmbedInteractionEvent(evt: IPointerEvent | IMouseEvent, unitId: string): boolean { + if (isInternalEditorID(unitId)) { + return false; + } + + const target = (evt as Event).target; + if (this._embedRuntimeFocusCoordinator?.isChildUnitRuntimeEvent(unitId, target, evt as Event)) { + return false; + } + + if (this._embedRuntimeFocusCoordinator?.isChildUnitInActiveSession(unitId)) { + return false; + } + + if (this._embedRuntimeFocusCoordinator?.shouldSuppressHostInteraction(unitId, target, evt as Event)) { + return true; + } + + return isEmbedInteractionEvent(evt); + } + + private _isEmbedChildInteractionActive(unitId: string): boolean { + return this._embedRuntimeFocusCoordinator?.shouldSuppressHostInteraction(unitId) === true; + } + private _commandExecutedListener() { const updateCommandList = [SetDocZoomRatioOperation.id]; @@ -250,6 +293,10 @@ export class DocSelectionRenderController extends Disposable implements IRenderM return; } + if (this._isEmbedChildInteractionActive(documentId)) { + return; + } + this._docSelectionManagerService.refreshSelection(); } }) @@ -268,6 +315,10 @@ export class DocSelectionRenderController extends Disposable implements IRenderM // and can be set to the previous cursor position in the future. // The skeleton of the editor has not been calculated at this moment, and it is determined whether it is an editor by its ID. if (!isInternalEditor) { + if (this._isEmbedChildInteractionActive(unitId)) { + return; + } + //TODO: @JOCS Only for docs. move to docs in the future. this._docSelectionRenderService.focus(); const docDataModel = this._context.unit; @@ -287,3 +338,47 @@ export class DocSelectionRenderController extends Disposable implements IRenderM })); } } + +function isEmbedInteractionEvent(evt: IPointerEvent | IMouseEvent): boolean { + const target = (evt as Event).target; + if (typeof Element !== 'undefined' && target instanceof Element && target.closest(`[${DOC_EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE}]`) != null) { + return true; + } + + if (typeof document === 'undefined') { + return false; + } + + const point = getEventClientPoint(evt, target); + const clientX = point?.clientX; + const clientY = point?.clientY; + if (typeof clientX !== 'number' || typeof clientY !== 'number' || !Number.isFinite(clientX) || !Number.isFinite(clientY)) { + return false; + } + + if (typeof document.elementFromPoint !== 'function') { + return false; + } + + return document.elementFromPoint(clientX, clientY)?.closest(`[${DOC_EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE}]`) != null; +} + +function getEventClientPoint(evt: IPointerEvent | IMouseEvent, target: EventTarget | null): { clientX: number; clientY: number } | undefined { + if (Number.isFinite(evt.clientX) && Number.isFinite(evt.clientY)) { + return { clientX: evt.clientX, clientY: evt.clientY }; + } + + if (typeof Element !== 'undefined' && target instanceof Element && Number.isFinite(evt.offsetX) && Number.isFinite(evt.offsetY)) { + const rect = target.getBoundingClientRect(); + return { + clientX: rect.left + evt.offsetX, + clientY: rect.top + evt.offsetY, + }; + } + + if (Number.isFinite(evt.x) && Number.isFinite(evt.y)) { + return { clientX: evt.x, clientY: evt.y }; + } + + return undefined; +} diff --git a/packages/docs-ui/src/controllers/render-controllers/doc.render-controller.ts b/packages/docs-ui/src/controllers/render-controllers/doc.render-controller.ts index 512ade4a8d94..ac82de6f3f41 100644 --- a/packages/docs-ui/src/controllers/render-controllers/doc.render-controller.ts +++ b/packages/docs-ui/src/controllers/render-controllers/doc.render-controller.ts @@ -24,6 +24,7 @@ import { takeUntil } from 'rxjs'; import { DOCS_COMPONENT_BACKGROUND_LAYER_INDEX, DOCS_COMPONENT_DEFAULT_Z_INDEX, DOCS_COMPONENT_HEADER_LAYER_INDEX, DOCS_COMPONENT_MAIN_LAYER_INDEX, DOCS_VIEW_KEY, VIEWPORT_KEY } from '../../basics/docs-view-key'; import { DocPageLayoutService } from '../../services/doc-page-layout.service'; import { resolveDocRenderBackground } from '../../services/doc-render-background'; +import { DocViewScaleService } from '../../services/doc-view-scale'; import { IEditorService } from '../../services/editor/editor-manager.service'; import { DocSelectionRenderService } from '../../services/selection/doc-selection-render.service'; @@ -38,6 +39,7 @@ export class DocRenderController extends RxDisposable implements IRenderModule { @IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService, @Inject(DocPageLayoutService) private readonly _docPageLayoutService: DocPageLayoutService, @Inject(DocSelectionManagerService) private readonly _textSelectionManagerService: DocSelectionManagerService, + @Inject(DocViewScaleService) private readonly _docViewScaleService: DocViewScaleService, @Inject(ThemeService) private readonly _themeService: ThemeService ) { super(); @@ -122,7 +124,9 @@ export class DocRenderController extends RxDisposable implements IRenderModule { // const hasScroll = this._configService.getConfig('hasScroll') as Nullable; // if (hasScroll !== false) { // eslint-disable-next-line no-new - new ScrollBar(viewMain); + new ScrollBar(viewMain, { + enableHorizontal: this._shouldEnableHorizontalScrollBar(), + }); // } scene.addLayer( @@ -147,6 +151,11 @@ export class DocRenderController extends RxDisposable implements IRenderModule { this._docSelectionRenderService.__attachScrollEvent(); } + private _shouldEnableHorizontalScrollBar(): boolean { + const options = this._docViewScaleService.getOptions(); + return !(options.mode === 'fit-width' && options.target === 'container' && options.align === 'start'); + } + private _addComponent() { const { scene, unit: documentModel, components } = this._context; const DEFAULT_PAGE_MARGIN_LEFT = 20; diff --git a/packages/docs-ui/src/controllers/render-controllers/zoom.render-controller.ts b/packages/docs-ui/src/controllers/render-controllers/zoom.render-controller.ts index 73d022d085a5..7008c2493c51 100644 --- a/packages/docs-ui/src/controllers/render-controllers/zoom.render-controller.ts +++ b/packages/docs-ui/src/controllers/render-controllers/zoom.render-controller.ts @@ -28,6 +28,7 @@ import { Inject, isInternalEditorID, IUniverInstanceService, + Optional, UniverInstanceType, } from '@univerjs/core'; import { DocSelectionManagerService, DocSkeletonManagerService } from '@univerjs/docs'; @@ -38,6 +39,7 @@ import { SetDocZoomRatioCommand } from '../../commands/commands/set-doc-zoom-rat import { SwitchDocModeCommand } from '../../commands/commands/switch-doc-mode.command'; import { SetDocZoomRatioOperation } from '../../commands/operations/set-doc-zoom-ratio.operation'; import { DocPageLayoutService } from '../../services/doc-page-layout.service'; +import { IDocEmbedInteractionBoundaryService } from '../../services/doc-embed-integration.service'; import { DocViewScaleService } from '../../services/doc-view-scale'; import { DEFAULT_MODERN_DOC_ZOOM_RATIO, getDocEffectiveZoomRatio } from '../../services/doc-zoom'; import { IEditorService } from '../../services/editor/editor-manager.service'; @@ -65,7 +67,8 @@ export class DocZoomRenderController extends Disposable implements IRenderModule @IEditorService private readonly _editorService: IEditorService, @Inject(DocPageLayoutService) private readonly _docPageLayoutService: DocPageLayoutService, @IRenderManagerService private readonly _renderManagerService: IRenderManagerService, - @Inject(DocViewScaleService) private readonly _docViewScaleService: DocViewScaleService + @Inject(DocViewScaleService) private readonly _docViewScaleService: DocViewScaleService, + @Optional(IDocEmbedInteractionBoundaryService) private readonly _embedInteractionBoundaryService?: IDocEmbedInteractionBoundaryService ) { super(); @@ -147,14 +150,23 @@ export class DocZoomRenderController extends Disposable implements IRenderModule this._docPageLayoutService.calculatePagePosition(); } - if (needRefreshSelection && !this._editorService.isEditor(this._context.unitId)) { + if ( + needRefreshSelection && + !this._editorService.isEditor(this._context.unitId) && + !this._embedInteractionBoundaryService?.hasRecentInteraction() + ) { this._textSelectionManagerService.refreshSelection(); } - if (isInternalEditorID(this._context.unitId)) { - return; + if (!isInternalEditorID(this._context.unitId)) { + docObject.scene.getTransformer()?.clearSelectedObjects(); + } + + const createOptions = this._univerInstanceService.getUnitCreateOptions(this._context.unitId); + if (createOptions?.embeddedRender === true || createOptions?.skipAutoRender === true) { + this._context.scene.makeDirty(); + this._context.scene.render(); } - docObject.scene.getTransformer()?.clearSelectedObjects(); } private _initZoomEventListener() { diff --git a/packages/docs-ui/src/controllers/ui.controller.ts b/packages/docs-ui/src/controllers/ui.controller.ts index c579d1a39976..59ac2c1406a9 100644 --- a/packages/docs-ui/src/controllers/ui.controller.ts +++ b/packages/docs-ui/src/controllers/ui.controller.ts @@ -21,6 +21,7 @@ import { Inject, Injector, IUniverInstanceService, + Optional, UniverInstanceType, } from '@univerjs/core'; import { IRenderManagerService } from '@univerjs/engine-render'; @@ -35,6 +36,7 @@ import { import { CoreHeaderFooterCommand, OpenHeaderFooterPanelCommand } from '../commands/commands/doc-header-footer.command'; import { SidebarDocHeaderFooterPanelOperation } from '../commands/operations/doc-header-footer-panel.operation'; import { floatToolbarMenuSchema, menuSchema } from '../menu/schema'; +import { IDocEmbedInteractionBoundaryService, IDocEmbedRuntimeFocusCoordinator } from '../services/doc-embed-integration.service'; import { DocSelectionRenderService } from '../services/selection/doc-selection-render.service'; import { TabShortCut } from '../shortcuts/format.shortcut'; import { @@ -63,7 +65,9 @@ export class DocUIController extends Disposable { @IUIPartsService protected readonly _uiPartsService: IUIPartsService, @IUniverInstanceService protected readonly _univerInstanceService: IUniverInstanceService, @IShortcutService protected readonly _shortcutService: IShortcutService, - @IConfigService protected readonly _configService: IConfigService + @IConfigService protected readonly _configService: IConfigService, + @Optional(IDocEmbedInteractionBoundaryService) _embedInteractionBoundaryService?: IDocEmbedInteractionBoundaryService, + @Optional(IDocEmbedRuntimeFocusCoordinator) protected readonly _embedRuntimeFocusCoordinator?: IDocEmbedRuntimeFocusCoordinator ) { super(); @@ -119,6 +123,10 @@ export class DocUIController extends Disposable { private _initFocusHandler(): void { this.disposeWithMe( this._layoutService.registerFocusHandler(UniverInstanceType.UNIVER_DOC, (unitId: string) => { + if (this._shouldPreserveEmbedFocus(unitId)) { + return; + } + const renderManagerService = this._injector.get(IRenderManagerService); const docSelectionRenderService = renderManagerService.getRenderById(unitId)!.with(DocSelectionRenderService); @@ -126,4 +134,12 @@ export class DocUIController extends Disposable { }) ); } + + private _shouldPreserveEmbedFocus(unitId: string): boolean { + if (this._embedRuntimeFocusCoordinator?.shouldSuppressHostInteraction(unitId)) { + return true; + } + + return false; + } } diff --git a/packages/docs-ui/src/embed-docs-custom-block-bleed.ts b/packages/docs-ui/src/embed-docs-custom-block-bleed.ts new file mode 100644 index 000000000000..ca060b53b6a3 --- /dev/null +++ b/packages/docs-ui/src/embed-docs-custom-block-bleed.ts @@ -0,0 +1,138 @@ +/** + * 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. + */ + +const DOCS_CUSTOM_BLOCK_VIEWPORT_INSET = 10; + +export interface IDocsCustomBlockBleedViewport { + bleedLeft: number; + bleedRight: number; + bleedWidth: number; + contentWidth: number; + virtualWidth: number; +} + +export interface IDocsCustomBlockBleedViewportHint { + bleedLeft?: number; + bleedWidth?: number; +} + +export function createDefaultDocsTableLikeCustomBlockBleedViewport(): IDocsCustomBlockBleedViewport { + if (typeof window === 'undefined') { + return { bleedLeft: 0, bleedRight: 0, bleedWidth: 1, contentWidth: 1, virtualWidth: 1 }; + } + + const bleedWidth = Math.max(1, window.innerWidth - DOCS_CUSTOM_BLOCK_VIEWPORT_INSET * 2); + return { + bleedLeft: 0, + bleedRight: 0, + bleedWidth, + contentWidth: 1, + virtualWidth: bleedWidth, + }; +} + +export function resolveDocsTableLikeCustomBlockBleedViewport(root: HTMLElement, contentWidth: number, hint?: IDocsCustomBlockBleedViewportHint): IDocsCustomBlockBleedViewport { + const rootRect = root.getBoundingClientRect(); + const rootWidth = Math.max(1, rootRect.width); + const normalizedContentWidth = Math.max(1, contentWidth); + const hintedBleedLeft = hint?.bleedLeft; + const hintedBleedWidth = hint?.bleedWidth; + if (Number.isFinite(hintedBleedWidth) && (hintedBleedWidth ?? 0) > 0) { + const bleedLeft = Math.max(0, hintedBleedLeft ?? 0); + const bleedWidth = Math.max(1, hintedBleedWidth!); + return { + bleedLeft, + bleedRight: Math.max(0, bleedWidth - bleedLeft - rootWidth), + bleedWidth, + contentWidth: normalizedContentWidth, + virtualWidth: Math.max(bleedWidth, bleedLeft + normalizedContentWidth), + }; + } + + if (normalizedContentWidth <= rootWidth) { + return { + bleedLeft: 0, + bleedRight: 0, + bleedWidth: rootWidth, + contentWidth: normalizedContentWidth, + virtualWidth: rootWidth, + }; + } + + const bounds = resolveDocsTableLikeCustomBlockBleedBounds(root); + const viewportLeft = bounds.left + DOCS_CUSTOM_BLOCK_VIEWPORT_INSET; + const viewportWidth = Math.max(1, bounds.width - DOCS_CUSTOM_BLOCK_VIEWPORT_INSET * 2); + const bleedLeft = Math.max(0, rootRect.left - viewportLeft); + const bleedRight = Math.max(0, viewportLeft + viewportWidth - rootRect.right); + + return { + bleedLeft, + bleedRight, + bleedWidth: viewportWidth, + contentWidth: normalizedContentWidth, + virtualWidth: Math.max(viewportWidth, bleedLeft + normalizedContentWidth), + }; +} + +export function resolveDocsTableLikeCustomBlockContentWidth(authoritativeContentWidth: number | undefined, fallbackContentWidth: number): number { + return Number.isFinite(authoritativeContentWidth) && (authoritativeContentWidth ?? 0) > 0 + ? authoritativeContentWidth! + : Math.max(1, fallbackContentWidth); +} + +export function resolveDocsTableLikeCustomBlockContentHeight(authoritativeContentHeight: number | undefined, fallbackContentHeight: number): number { + return Number.isFinite(authoritativeContentHeight) && (authoritativeContentHeight ?? 0) > 0 + ? authoritativeContentHeight! + : Math.max(1, fallbackContentHeight); +} + +function resolveDocsTableLikeCustomBlockBleedBounds(root: HTMLElement): { left: number; width: number } { + const clippingAncestor = findClippingAncestor(root); + if (clippingAncestor) { + const rect = clippingAncestor.getBoundingClientRect(); + if (Number.isFinite(rect.width) && rect.width > 0) { + return { left: rect.left, width: rect.width }; + } + } + + return { + left: 0, + width: typeof window === 'undefined' ? 1 : Math.max(1, window.innerWidth), + }; +} + +function findClippingAncestor(root: HTMLElement): HTMLElement | null { + let current = root.parentElement; + while (current && current !== document.body && current !== document.documentElement) { + if (clipsOverflow(current)) { + return current; + } + current = current.parentElement; + } + + return null; +} + +function clipsOverflow(element: HTMLElement): boolean { + const style = window.getComputedStyle(element); + return hasClippingOverflow(style.overflow) || + hasClippingOverflow(style.overflowX) || + hasClippingOverflow(style.overflowY); +} + +function hasClippingOverflow(value: string): boolean { + return value === 'hidden' || value === 'auto' || value === 'scroll' || value === 'clip'; +} diff --git a/packages/docs-ui/src/embed-docs-custom-block-refresh.ts b/packages/docs-ui/src/embed-docs-custom-block-refresh.ts new file mode 100644 index 000000000000..95e2a8245e8c --- /dev/null +++ b/packages/docs-ui/src/embed-docs-custom-block-refresh.ts @@ -0,0 +1,145 @@ +/** + * 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 type { UniverInstanceType } from '@univerjs/core'; +import { isSheetLikeDocsCustomBlockChildType } from '@univerjs/docs'; + +export function collectDocsTableLikeEmbedChildUnitIds( + drawings: Record | undefined, + resolveChildUnitId?: (data: Record) => string | undefined +): Set { + const childUnitIds = new Set(); + + Object.values(drawings ?? {}).forEach((drawing) => { + const data = getDrawingData(drawing); + const childUnitId = data + ? resolveChildUnitId?.(data) ?? (typeof data.childUnitId === 'string' ? data.childUnitId : undefined) + : undefined; + const childType = typeof data?.childType === 'number' ? data.childType as UniverInstanceType : undefined; + if (!childUnitId || !isSheetLikeDocsCustomBlockChildType(childType)) { + return; + } + + childUnitIds.add(childUnitId); + }); + + return childUnitIds; +} + +function getDrawingData(drawing: unknown): Record | undefined { + if (!drawing || typeof drawing !== 'object') { + return undefined; + } + + const data = (drawing as { data?: unknown }).data; + return data && typeof data === 'object' ? data as Record : undefined; +} + +export function getCommandUnitId(commandParams: unknown): string | undefined { + if (!commandParams || typeof commandParams !== 'object') { + return undefined; + } + + const params = commandParams as { unitID?: unknown; unitId?: unknown }; + if (typeof params.unitId === 'string') { + return params.unitId; + } + + return typeof params.unitID === 'string' ? params.unitID : undefined; +} + +export function shouldRefreshDocsCustomBlockSizeForCommand(params: { + commandId?: string; + childUnitIds: Set; + commandParams: unknown; + hostUnitId: string; +}): boolean { + if (params.commandId && isDocsCustomBlockLayoutNeutralCommand(params.commandId)) { + return false; + } + + const commandUnitId = getCommandUnitId(params.commandParams); + return Boolean(commandUnitId && commandUnitId !== params.hostUnitId && params.childUnitIds.has(commandUnitId)); +} + +const DOCS_CUSTOM_BLOCK_LAYOUT_NEUTRAL_COMMAND_IDS = new Set([ + 'sheet.command.expand-selection', + 'sheet.command.move-selection', + 'sheet.operation.scroll-to-cell', + 'sheet.operation.scroll-to-range', + 'sheet.operation.set-activate-cell-edit', + 'sheet.operation.set-cell-edit-visible', + 'sheet.operation.set-cell-edit-visible-arrow', + 'sheet.operation.set-cell-edit-visible-f2', + 'sheet.operation.set-format-painter', + 'sheet.operation.set-scroll', + 'sheet.operation.set-selections', + 'sheet.operation.set-zoom-ratio', +]); + +function isDocsCustomBlockLayoutNeutralCommand(commandId: string): boolean { + return DOCS_CUSTOM_BLOCK_LAYOUT_NEUTRAL_COMMAND_IDS.has(commandId); +} + +export interface IDocsCustomBlockSizeRefreshScheduler { + dispose: () => void; + schedule: () => void; +} + +export function createDocsCustomBlockSizeRefreshScheduler( + refresh: () => void, + frameApi: { + cancelFrame: (handle: number) => void; + requestFrame: (callback: () => void) => number; + } = getDefaultFrameApi() +): IDocsCustomBlockSizeRefreshScheduler { + let pendingFrame: number | undefined; + + return { + dispose: () => { + if (pendingFrame == null) { + return; + } + + frameApi.cancelFrame(pendingFrame); + pendingFrame = undefined; + }, + schedule: () => { + if (pendingFrame != null) { + return; + } + + pendingFrame = frameApi.requestFrame(() => { + pendingFrame = undefined; + refresh(); + }); + }, + }; +} + +function getDefaultFrameApi(): { cancelFrame: (handle: number) => void; requestFrame: (callback: () => void) => number } { + if (typeof requestAnimationFrame === 'function' && typeof cancelAnimationFrame === 'function') { + return { + cancelFrame: (handle) => cancelAnimationFrame(handle), + requestFrame: (callback) => requestAnimationFrame(callback), + }; + } + + return { + cancelFrame: (handle) => clearTimeout(handle), + requestFrame: (callback) => setTimeout(callback, 16) as unknown as number, + }; +} diff --git a/packages/docs-ui/src/embed-docs-custom-block-scroll.ts b/packages/docs-ui/src/embed-docs-custom-block-scroll.ts new file mode 100644 index 000000000000..5bc8be80ba28 --- /dev/null +++ b/packages/docs-ui/src/embed-docs-custom-block-scroll.ts @@ -0,0 +1,64 @@ +/** + * 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. + */ + +export interface IDocsTableLikeCustomBlockScrollOptions { + maxScrollLeft?: number; +} + +export function scrollDocsTableLikeCustomBlockLive(event: WheelEvent, live: HTMLElement, options: IDocsTableLikeCustomBlockScrollOptions = {}): boolean { + if (event.defaultPrevented || event.ctrlKey || event.metaKey) { + return false; + } + + const deltaX = event.deltaX || (event.shiftKey ? event.deltaY : 0); + const deltaY = event.shiftKey ? 0 : event.deltaY; + const previousLeft = live.scrollLeft; + const previousTop = live.scrollTop; + const maxScrollLeft = resolveMaxScrollLeft(live, options.maxScrollLeft); + + if (deltaX && canScrollX(live, deltaX, maxScrollLeft)) { + live.scrollLeft = clampScroll(previousLeft + deltaX, 0, maxScrollLeft); + } + + if (deltaY && canScrollY(live, deltaY)) { + live.scrollTop = clampScroll(previousTop + deltaY, 0, live.scrollHeight - live.clientHeight); + } + + return live.scrollLeft !== previousLeft || live.scrollTop !== previousTop; +} + +function canScrollX(element: HTMLElement, deltaX: number, maxScrollLeft: number): boolean { + return element.scrollWidth > element.clientWidth && + (deltaX < 0 ? element.scrollLeft > 0 : element.scrollLeft < maxScrollLeft); +} + +function canScrollY(element: HTMLElement, deltaY: number): boolean { + return element.scrollHeight > element.clientHeight && + (deltaY < 0 ? element.scrollTop > 0 : element.scrollTop + element.clientHeight < element.scrollHeight); +} + +function clampScroll(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} + +function resolveMaxScrollLeft(element: HTMLElement, requestedMaxScrollLeft: number | undefined): number { + const nativeMaxScrollLeft = Math.max(0, element.scrollWidth - element.clientWidth); + if (!Number.isFinite(requestedMaxScrollLeft)) { + return nativeMaxScrollLeft; + } + + return clampScroll(requestedMaxScrollLeft ?? nativeMaxScrollLeft, 0, nativeMaxScrollLeft); +} diff --git a/packages/docs-ui/src/embed-host-anchor.ts b/packages/docs-ui/src/embed-host-anchor.ts new file mode 100644 index 000000000000..1e99b012cba3 --- /dev/null +++ b/packages/docs-ui/src/embed-host-anchor.ts @@ -0,0 +1,122 @@ +/** + * 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 type { UniverInstanceType } from '@univerjs/core'; +import { DocumentFlavor } from '@univerjs/core'; +import { isSheetLikeDocsCustomBlockChildType, resolveDocsCustomBlockSize } from '@univerjs/docs'; + +const MODERN_DOCS_CUSTOM_BLOCK_VIEWPORT_INSET = 10; + +export interface IDocsCustomBlockRenderViewportParams { + childType?: UniverInstanceType; + contentHeight?: number; + contentWidth?: number; + docsLeft?: number; + documentFlavor?: DocumentFlavor; + fallbackHeight?: number; + fallbackWidth?: number; + pageMarginLeft?: number; + pageMarginRight?: number; + pageWidth?: number; + scale?: number; + visibleCanvasHeight?: number; + visibleCanvasLeft?: number; + visibleCanvasWidth?: number; +} + +export interface IDocsCustomBlockLayoutViewport { + bleedLeft?: number; + bleedWidth?: number; + contentHeight?: number; + contentWidth?: number; + height: number; + layoutWidth?: number; + offsetLeft?: number; + viewportHeight?: number; + width: number; +} + +export function resolveDocsCustomBlockRenderViewport(params: IDocsCustomBlockRenderViewportParams): IDocsCustomBlockLayoutViewport { + const defaultSize = resolveDocsCustomBlockSize(params.childType); + const fallbackWidth = params.fallbackWidth ?? defaultSize.width; + const fallbackHeight = params.fallbackHeight ?? defaultSize.height; + const sheetLike = isSheetLikeDocsCustomBlockChildType(params.childType); + const contentHeight = Number.isFinite(params.contentHeight) && (params.contentHeight ?? 0) > 0 + ? params.contentHeight! + : fallbackHeight; + const visibleCanvasHeight = Number.isFinite(params.visibleCanvasHeight) && (params.visibleCanvasHeight ?? 0) > 0 + ? params.visibleCanvasHeight! + : undefined; + const height = sheetLike ? contentHeight : fallbackHeight; + const viewportHeight = sheetLike && visibleCanvasHeight != null ? Math.min(contentHeight, visibleCanvasHeight) : height; + + if (!sheetLike) { + return { + height, + width: fallbackWidth, + }; + } + + const pageWidth = params.pageWidth; + const pageMarginLeft = params.pageMarginLeft ?? 0; + const pageMarginRight = params.pageMarginRight ?? 0; + const pageContentWidth = Number.isFinite(pageWidth) + ? Math.max(0, pageWidth! - pageMarginLeft - pageMarginRight) + : fallbackWidth; + const contentWidth = Number.isFinite(params.contentWidth) && (params.contentWidth ?? 0) > 0 + ? params.contentWidth! + : fallbackWidth; + + if (params.documentFlavor !== DocumentFlavor.MODERN || !Number.isFinite(pageWidth)) { + const layoutWidth = Math.min(contentWidth, pageContentWidth || contentWidth); + return { + contentHeight, + contentWidth, + height, + layoutWidth, + offsetLeft: 0, + viewportHeight, + width: layoutWidth, + }; + } + + const scale = params.scale && params.scale > 0 ? params.scale : 1; + const inset = MODERN_DOCS_CUSTOM_BLOCK_VIEWPORT_INSET / scale; + const docsLeft = params.docsLeft ?? 0; + const fallbackViewportLeft = docsLeft + inset; + const fallbackViewportWidth = Math.max(0, pageWidth! - inset * 2); + const hasVisibleCanvas = Number.isFinite(params.visibleCanvasLeft) && + Number.isFinite(params.visibleCanvasWidth) && + (params.visibleCanvasWidth ?? 0) > 0; + const viewportLeft = hasVisibleCanvas ? params.visibleCanvasLeft! + inset : fallbackViewportLeft; + const viewportWidth = hasVisibleCanvas ? Math.max(0, params.visibleCanvasWidth! - inset * 2) : fallbackViewportWidth; + const paragraphTextStart = docsLeft + pageMarginLeft; + const leadingInsetLeft = Math.max(0, paragraphTextStart - viewportLeft); + + const layoutWidth = Math.min(contentWidth, pageContentWidth || contentWidth); + + return { + bleedLeft: leadingInsetLeft, + bleedWidth: viewportWidth, + contentHeight, + contentWidth, + height, + layoutWidth, + offsetLeft: 0, + viewportHeight, + width: layoutWidth, + }; +} diff --git a/packages/docs-ui/src/index.ts b/packages/docs-ui/src/index.ts index 3942e6c79667..cf9a1a62d714 100644 --- a/packages/docs-ui/src/index.ts +++ b/packages/docs-ui/src/index.ts @@ -140,13 +140,25 @@ export { SetDocZoomRatioOperation } from './commands/operations/set-doc-zoom-rat export type { ISetDocZoomRatioOperationParams } from './commands/operations/set-doc-zoom-ratio.operation'; export { getCommandSkeleton } from './commands/util'; export type { DocFitAlign, DocFitMode, DocFitTarget, IDocFitToWidthOptions, IUniverDocsUIConfig } from './config/config'; -export { DEFAULT_DOC_FIT_TO_WIDTH_OPTIONS } from './config/config'; +export { DEFAULT_DOC_FIT_TO_WIDTH_OPTIONS, DOCS_UI_PLUGIN_CONFIG_KEY } from './config/config'; export { DocBackScrollRenderController } from './controllers/render-controllers/back-scroll.render-controller'; export { DocParagraphPlaceholderRenderController, } from './controllers/render-controllers/doc-paragraph-placeholder.render-controller'; export { DocRenderController } from './controllers/render-controllers/doc.render-controller'; export { DocUIController } from './controllers/ui.controller'; +export { + createDefaultDocsTableLikeCustomBlockBleedViewport, + resolveDocsTableLikeCustomBlockBleedViewport, + resolveDocsTableLikeCustomBlockContentHeight, + resolveDocsTableLikeCustomBlockContentWidth, +} from './embed-docs-custom-block-bleed'; +export type { IDocsCustomBlockBleedViewport, IDocsCustomBlockBleedViewportHint } from './embed-docs-custom-block-bleed'; +export { collectDocsTableLikeEmbedChildUnitIds, createDocsCustomBlockSizeRefreshScheduler, shouldRefreshDocsCustomBlockSizeForCommand } from './embed-docs-custom-block-refresh'; +export { scrollDocsTableLikeCustomBlockLive } from './embed-docs-custom-block-scroll'; +export type { IDocsTableLikeCustomBlockScrollOptions } from './embed-docs-custom-block-scroll'; +export { resolveDocsCustomBlockRenderViewport } from './embed-host-anchor'; +export type { IDocsCustomBlockLayoutViewport, IDocsCustomBlockRenderViewportParams } from './embed-host-anchor'; export { AlignMenuItemFactory, BackgroundColorSelectorMenuItemFactory, @@ -186,6 +198,8 @@ export { convertBodyToHtml } from './services/clipboard/udm-to-html/convertor'; export { DocHtmlExportService } from './services/clipboard/udm-to-html/doc-html-export.service'; export type { DocHtmlExportTransformer } from './services/clipboard/udm-to-html/doc-html-export.service'; export { DocAutoFormatService } from './services/doc-auto-format.service'; +export { DOC_EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE, IDocEmbedInteractionBoundaryService, IDocEmbedRuntimeFocusCoordinator } from './services/doc-embed-integration.service'; +export type { IDocEmbedInteractionBoundaryService as IDocEmbedInteractionBoundaryServiceType, IDocEmbedRuntimeFocusCoordinator as IDocEmbedRuntimeFocusCoordinatorType } from './services/doc-embed-integration.service'; export { DocEventManagerService, getListMarkerFallbackBound, @@ -193,6 +207,7 @@ export { } from './services/doc-event-manager.service'; export type { IBulletBound, IMutiPageParagraphBound } from './services/doc-event-manager.service'; export { DocIMEInputManagerService } from './services/doc-ime-input-manager.service'; +export { DocPageLayoutService } from './services/doc-page-layout.service'; export { DocParagraphMenuService } from './services/doc-paragraph-menu.service'; export { calcDocRangePositions, DocCanvasPopManagerService } from './services/doc-popup-manager.service'; export { DocPrintInterceptorService } from './services/doc-print-interceptor.service'; @@ -200,6 +215,7 @@ export type { IDocPrintComponentContext, IDocPrintContext } from './services/doc export { DocsRenderService } from './services/docs-render.service'; export { Editor } from './services/editor/editor'; export { EditorService, IEditorService } from './services/editor/editor-manager.service'; +export { DocFloatMenuService } from './services/float-menu.service'; export { isInSameTableCell, isValidRectRange, @@ -233,3 +249,5 @@ export type { } from './views/rich-text-editor/hooks'; export { RichTextEditor } from './views/RichTextEditor'; export type { IRichTextEditorProps } from './views/RichTextEditor'; +export { createDocsCustomBlockInsertMutation, createDocsCustomBlockRemoveMutation, createEmbedDocsCustomBlockData, createInsertCustomBlockActions, createRemoveCustomBlockActions, EMBED_DOCS_CUSTOM_BLOCK_DEFAULT_COMPONENT_KEY, isSheetLikeDocsCustomBlockChildType, resolveDocsCustomBlockSize } from '@univerjs/docs'; +export type { IDocsCustomBlockMutationParams, IEmbedDocsCustomBlockData } from '@univerjs/docs'; diff --git a/packages/docs-ui/src/menu/menu.ts b/packages/docs-ui/src/menu/menu.ts index 705d5ead4e6b..bd375edbbec9 100644 --- a/packages/docs-ui/src/menu/menu.ts +++ b/packages/docs-ui/src/menu/menu.ts @@ -88,8 +88,34 @@ import { DocCreateTableOperation } from '../commands/operations/doc-create-table import { DocOpenPageSettingCommand } from '../commands/operations/open-page-setting.operation'; import { getCommandSkeleton } from '../commands/util'; import { DocMenuStyleService } from '../services/doc-menu-style.service'; +import { IDocEmbedRuntimeFocusCoordinator } from '../services/doc-embed-integration.service'; import { BULLET_LIST_TYPE_COMPONENT, ORDER_LIST_TYPE_COMPONENT } from '../views/list-type-picker/index'; +export function shouldSuppressDocMenuStateRefresh(accessor: IAccessor): boolean { + let univerInstanceService: IUniverInstanceService; + let focusCoordinator: IDocEmbedRuntimeFocusCoordinator; + + try { + univerInstanceService = accessor.get(IUniverInstanceService); + } catch (error) { + if (isInjectorDisposedError(error)) { + return true; + } + + throw error; + } + + try { + focusCoordinator = accessor.get(IDocEmbedRuntimeFocusCoordinator); + } catch { + return false; + } + + const docDataModel = univerInstanceService.getCurrentUnitOfType(UniverInstanceType.UNIVER_DOC); + const unitId = docDataModel?.getUnitId(); + return focusCoordinator.shouldSuppressHostInteraction(unitId); +} + function getInsertTableHiddenObservable( accessor: IAccessor ): Observable { @@ -364,6 +390,10 @@ export function BoldMenuItemFactory(accessor: IAccessor): IMenuButtonItem((subscriber) => { const calc = () => { + if (shouldSuppressDocMenuStateRefresh(accessor)) { + return; + } + const textRun = getFontStyleAtCursor(accessor); if (textRun == null) { @@ -403,6 +433,10 @@ export function ItalicMenuItemFactory(accessor: IAccessor): IMenuButtonItem((subscriber) => { const calc = () => { + if (shouldSuppressDocMenuStateRefresh(accessor)) { + return; + } + const textRun = getFontStyleAtCursor(accessor); if (textRun == null) { @@ -442,6 +476,10 @@ export function UnderlineMenuItemFactory(accessor: IAccessor): IMenuButtonItem((subscriber) => { const calc = () => { + if (shouldSuppressDocMenuStateRefresh(accessor)) { + return; + } + const textRun = getFontStyleAtCursor(accessor); if (textRun == null) { @@ -481,6 +519,10 @@ export function StrikeThroughMenuItemFactory(accessor: IAccessor): IMenuButtonIt tooltip: 'docs-ui.toolbar.strikethrough', activated$: new Observable((subscriber) => { const calc = () => { + if (shouldSuppressDocMenuStateRefresh(accessor)) { + return; + } + const textRun = getFontStyleAtCursor(accessor); if (textRun == null) { @@ -519,6 +561,10 @@ export function SubscriptMenuItemFactory(accessor: IAccessor): IMenuButtonItem((subscriber) => { const calc = () => { + if (shouldSuppressDocMenuStateRefresh(accessor)) { + return; + } + const textRun = getFontStyleAtCursor(accessor); if (textRun == null) { @@ -557,6 +603,10 @@ export function SuperscriptMenuItemFactory(accessor: IAccessor): IMenuButtonItem tooltip: 'docs-ui.toolbar.superscript', activated$: new Observable((subscriber) => { const calc = () => { + if (shouldSuppressDocMenuStateRefresh(accessor)) { + return; + } + const textRun = getFontStyleAtCursor(accessor); if (textRun == null) { @@ -613,6 +663,10 @@ export function FontFamilySelectorMenuItemFactory(accessor: IAccessor): IMenuSel const defaultValue = DEFAULT_STYLES.ff; const calc = () => { + if (shouldSuppressDocMenuStateRefresh(accessor)) { + return; + } + const textRun = getFontStyleAtCursor(accessor); if (textRun == null) { @@ -660,6 +714,10 @@ export function FontSizeSelectorMenuItemFactory(accessor: IAccessor): IMenuSelec value$: new Observable((subscriber) => { const DEFAULT_SIZE = DEFAULT_STYLES.fs; const calc = () => { + if (shouldSuppressDocMenuStateRefresh(accessor)) { + return; + } + const textRun = getFontStyleAtCursor(accessor); if (textRun == null) { subscriber.next(DEFAULT_SIZE); @@ -712,6 +770,10 @@ export function HeadingSelectorMenuItemFactory(accessor: IAccessor): IMenuSelect value$: new Observable((subscriber) => { const DEFAULT_TYPE = NamedStyleType.NORMAL_TEXT; const calc = () => { + if (shouldSuppressDocMenuStateRefresh(accessor)) { + return; + } + const paragraph = getParagraphStyleAtCursor(accessor); if (paragraph == null) { subscriber.next(DEFAULT_TYPE); @@ -823,6 +885,10 @@ export function FloatTextStyleMenuItemFactory(accessor: IAccessor): IMenuSelecto selections: FLOAT_TEXT_STYLE_OPTIONS, value$: new Observable((subscriber) => { const calc = () => { + if (shouldSuppressDocMenuStateRefresh(accessor)) { + return; + } + subscriber.next(normalizeFloatingTextStyleValue(getParagraphStyleAtCursor(accessor))); }; @@ -868,6 +934,10 @@ export function TextColorSelectorMenuItemFactory(accessor: IAccessor): IMenuSele value$: new Observable((subscriber) => { const defaultValue = DEFAULT_STYLES.cl.rgb; const calc = () => { + if (shouldSuppressDocMenuStateRefresh(accessor)) { + return; + } + const textRun = getFontStyleAtCursor(accessor); if (!textRun) { @@ -895,6 +965,10 @@ export function TextColorSelectorMenuItemFactory(accessor: IAccessor): IMenuSele const disposable = commandService.onCommandExecuted((c) => { if (c.id === SetInlineFormatTextColorCommand.id) { + if (shouldSuppressDocMenuStateRefresh(accessor)) { + return; + } + const color = (c.params as { value: string }).value; subscriber.next(color ?? defaultColor); } @@ -976,6 +1050,10 @@ export function AlignLeftMenuItemFactory(accessor: IAccessor): IMenuButtonItem((subscriber) => { const calc = () => { + if (shouldSuppressDocMenuStateRefresh(accessor)) { + subscriber.next(HorizontalAlign.LEFT); + return; + } + const paragraph = getParagraphStyleAtCursor(accessor); subscriber.next(paragraph?.paragraphStyle?.horizontalAlign ?? HorizontalAlign.LEFT); @@ -1172,6 +1267,10 @@ const listValueFactory$ = (accessor: IAccessor) => { const univerInstanceService = accessor.get(IUniverInstanceService); const docSelectionManagerService = accessor.get(DocSelectionManagerService); const subscription = univerInstanceService.focused$.subscribe((unitId) => { + if (shouldSuppressDocMenuStateRefresh(accessor)) { + return; + } + if (unitId == null) { return; } @@ -1332,6 +1431,10 @@ export function BackgroundColorSelectorMenuItemFactory(accessor: IAccessor): IMe value$: new Observable((subscriber) => { const defaultValue = themeService.getColorFromTheme('primary.600'); const calc = () => { + if (shouldSuppressDocMenuStateRefresh(accessor)) { + return; + } + const textRun = getFontStyleAtCursor(accessor); if (!textRun) { @@ -1360,6 +1463,10 @@ export function BackgroundColorSelectorMenuItemFactory(accessor: IAccessor): IMe const disposable = commandService.onCommandExecuted((c) => { if (c.id === SetInlineFormatTextBackgroundColorCommand.id) { + if (shouldSuppressDocMenuStateRefresh(accessor)) { + return; + } + const color = (c.params as { value: string }).value; subscriber.next(color ?? defaultColor); } @@ -1374,9 +1481,24 @@ export function BackgroundColorSelectorMenuItemFactory(accessor: IAccessor): IMe } function getFontStyleAtCursor(accessor: IAccessor) { - const univerInstanceService = accessor.get(IUniverInstanceService); - const textSelectionService = accessor.get(DocSelectionManagerService); - const docMenuStyleService = accessor.get(DocMenuStyleService); + if (shouldSuppressDocMenuStateRefresh(accessor)) { + return; + } + + let univerInstanceService: IUniverInstanceService; + let textSelectionService: DocSelectionManagerService; + let docMenuStyleService: DocMenuStyleService; + try { + univerInstanceService = accessor.get(IUniverInstanceService); + textSelectionService = accessor.get(DocSelectionManagerService); + docMenuStyleService = accessor.get(DocMenuStyleService); + } catch (error) { + if (isInjectorDisposedError(error)) { + return; + } + + throw error; + } const docDataModel = univerInstanceService.getCurrentUnitOfType(UniverInstanceType.UNIVER_DOC); const docRanges = textSelectionService.getDocRanges(); @@ -1422,8 +1544,22 @@ function getFontStyleAtCursor(accessor: IAccessor) { } export function getParagraphStyleAtCursor(accessor: IAccessor) { - const univerInstanceService = accessor.get(IUniverInstanceService); - const textSelectionService = accessor.get(DocSelectionManagerService); + if (shouldSuppressDocMenuStateRefresh(accessor)) { + return; + } + + let univerInstanceService: IUniverInstanceService; + let textSelectionService: DocSelectionManagerService; + try { + univerInstanceService = accessor.get(IUniverInstanceService); + textSelectionService = accessor.get(DocSelectionManagerService); + } catch (error) { + if (isInjectorDisposedError(error)) { + return; + } + + throw error; + } const docDataModel = univerInstanceService.getCurrentUnitOfType(UniverInstanceType.UNIVER_DOC); @@ -1456,6 +1592,15 @@ export function getParagraphStyleAtCursor(accessor: IAccessor) { return null; } +function isInjectorDisposedError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false; + } + + return error.name === 'InjectorAlreadyDisposedError' || + error.message.includes('Injector cannot be accessed after it was disposed'); +} + export function PageSettingMenuItemFactory(accessor: IAccessor): IMenuButtonItem { return { id: DocOpenPageSettingCommand.id, diff --git a/packages/docs-ui/src/menu/paragraph-menu.ts b/packages/docs-ui/src/menu/paragraph-menu.ts index 6b74441c8a29..d4de3124caae 100644 --- a/packages/docs-ui/src/menu/paragraph-menu.ts +++ b/packages/docs-ui/src/menu/paragraph-menu.ts @@ -64,6 +64,7 @@ import { BackgroundColorSelectorMenuItemFactory, disableMenuWhenNoDocRange, getParagraphStyleAtCursor, + shouldSuppressDocMenuStateRefresh, TextColorSelectorMenuItemFactory, } from './menu'; @@ -103,6 +104,10 @@ function getHeadingActivatedObservable(accessor: IAccessor, headingType: NamedSt return new Observable((subscriber) => { const DEFAULT_TYPE = NamedStyleType.NORMAL_TEXT; const calc = () => { + if (shouldSuppressDocMenuStateRefresh(accessor)) { + return; + } + const paragraph = getParagraphStyleAtCursor(accessor); if (paragraph == null) { subscriber.next(DEFAULT_TYPE === headingType); diff --git a/packages/docs-ui/src/plugin.ts b/packages/docs-ui/src/plugin.ts index 9791c63da1c6..a9a3311ecc0b 100644 --- a/packages/docs-ui/src/plugin.ts +++ b/packages/docs-ui/src/plugin.ts @@ -392,6 +392,11 @@ export class UniverDocsUIPlugin extends Plugin { if (!doc) return; const id = doc.getUnitId(); + const createOptions = currentService.getUnitCreateOptions(id); + if (createOptions?.makeCurrent === false) { + return; + } + if (!editorService.isEditor(id)) { currentService.focusUnit(doc.getUnitId()); } diff --git a/packages/docs-ui/src/services/__tests__/doc-paragraph-menu.service.spec.ts b/packages/docs-ui/src/services/__tests__/doc-paragraph-menu.service.spec.ts index 223024760cca..ec1012d62952 100644 --- a/packages/docs-ui/src/services/__tests__/doc-paragraph-menu.service.spec.ts +++ b/packages/docs-ui/src/services/__tests__/doc-paragraph-menu.service.spec.ts @@ -19,7 +19,7 @@ import { BlockType, DataStreamTreeTokenType, DOC_RANGE_TYPE, DocumentBlockRangeT import { DocumentEditArea } from '@univerjs/engine-render'; import { BehaviorSubject, Subject } from 'rxjs'; import { describe, expect, it, vi } from 'vitest'; -import { DOC_PARAGRAPH_MENU_COMPONENT_KEY, DOC_TABLE_BLOCK_MENU_COMPONENT_KEY } from '../../views/ParagraphMenu'; +import { DOC_PARAGRAPH_MENU_COMPONENT_KEY, DOC_TABLE_BLOCK_MENU_COMPONENT_KEY } from '../../views/paragraph-menu/component-keys'; import { getPreferredParagraphBoundsInRange, getTableBlockMenuHoverRect, diff --git a/packages/docs-ui/src/services/__tests__/doc-popup-manager.service.spec.ts b/packages/docs-ui/src/services/__tests__/doc-popup-manager.service.spec.ts index fc051266a98e..c7b56d059eba 100644 --- a/packages/docs-ui/src/services/__tests__/doc-popup-manager.service.spec.ts +++ b/packages/docs-ui/src/services/__tests__/doc-popup-manager.service.spec.ts @@ -62,6 +62,8 @@ class TestRenderManagerService { style: { width: '1000px' }, }; + popupInjector = new Injector(); + getInjector = vi.fn(() => this.popupInjector); readonly onTransformChange$ = new EventSubject(); readonly onScrollAfter$ = new EventSubject(); @@ -78,6 +80,7 @@ class TestRenderManagerService { engine: { getCanvasElement: () => this.canvasElement, }, + getInjector: this.getInjector, mainComponent: { getOffsetConfig: () => ({ docsLeft: 0, @@ -113,9 +116,15 @@ class TestRenderManagerService { } class TestUniverInstanceService { + embeddedUnitIds = new Set(); + getUnit(unitId: string) { return unitId === 'missing-doc-data' ? undefined : {}; } + + getUnitCreateOptions(unitId: string) { + return this.embeddedUnitIds.has(unitId) ? { embeddedRender: true } : undefined; + } } class TestCommandService { @@ -152,6 +161,7 @@ function createService() { service: injector.get(DocCanvasPopManagerService), popupService: injector.get(ICanvasPopupService) as unknown as TestCanvasPopupService, renderManagerService: injector.get(IRenderManagerService) as unknown as TestRenderManagerService, + univerInstanceService: injector.get(IUniverInstanceService) as unknown as TestUniverInstanceService, commandService: injector.get(ICommandService) as unknown as TestCommandService, }; } @@ -198,6 +208,19 @@ describe('DocCanvasPopManagerService', () => { expect(anchorRect$?.value).toEqual({ left: 25, right: 175, top: 50, bottom: 80 }); }); + it('uses a scoped popup injector only for embedded document render units', () => { + const { service, popupService, renderManagerService, univerInstanceService } = createService(); + + service.attachPopupToRect({ left: 10, right: 110, top: 20, bottom: 40 }, { componentKey: 'normal-popup' }, 'doc-1'); + expect(popupService.popups.get('popup-1')?.connectorInjector).toBeUndefined(); + expect(renderManagerService.getInjector).not.toHaveBeenCalled(); + + univerInstanceService.embeddedUnitIds.add('doc-1'); + service.attachPopupToRect({ left: 10, right: 110, top: 20, bottom: 40 }, { componentKey: 'embed-popup' }, 'doc-1'); + expect(popupService.popups.get('popup-2')?.connectorInjector).toBe(renderManagerService.popupInjector); + expect(renderManagerService.getInjector).toHaveBeenCalledTimes(1); + }); + it('refreshes function-based rect popup anchors after scroll and rich text changes', () => { const { service, popupService, renderManagerService, commandService } = createService(); const rect = { left: 10, right: 110, top: 20, bottom: 40 }; @@ -217,6 +240,44 @@ describe('DocCanvasPopManagerService', () => { expect(anchorRect$?.value).toEqual({ left: 20, right: 120, top: 10, bottom: 30 }); }); + it('does not refresh rect popup anchors for rich text changes from another document', () => { + const { service, popupService, commandService } = createService(); + const rect = { left: 10, right: 110, top: 20, bottom: 40 }; + const getRect = vi.fn(() => rect); + + service.attachPopupToRect(getRect, { componentKey: 'dynamic-rect' }, 'doc-1'); + const popup = popupService.popups.get('popup-1'); + const anchorRect$ = popup?.anchorRect$ as { value?: unknown } | undefined; + + rect.left = 30; + rect.right = 130; + commandService.emit(RichTextEditingMutation.id, { unitId: 'slide-shape-editor' }); + + expect(getRect).toHaveBeenCalledTimes(1); + expect(anchorRect$?.value).toEqual({ left: 20, right: 120, top: 40, bottom: 60 }); + }); + + it('keeps the last rect popup anchor when a stale dynamic rect throws during refresh', () => { + const { service, popupService, commandService } = createService(); + let stale = false; + const getRect = vi.fn(() => { + if (stale) { + throw new TypeError('Cannot read properties of null (reading clone)'); + } + + return { left: 10, right: 110, top: 20, bottom: 40 }; + }); + + service.attachPopupToRect(getRect, { componentKey: 'stale-dynamic-rect' }, 'doc-1'); + const popup = popupService.popups.get('popup-1'); + const anchorRect$ = popup?.anchorRect$ as { value?: unknown } | undefined; + + stale = true; + + expect(() => commandService.emit(RichTextEditingMutation.id, { unitId: 'doc-1' })).not.toThrow(); + expect(anchorRect$?.value).toEqual({ left: 20, right: 120, top: 40, bottom: 60 }); + }); + it('ignores stale rect popup updates after the render canvas is released', () => { const { service, popupService, renderManagerService, commandService } = createService(); diff --git a/packages/docs-ui/src/services/__tests__/doc-view-scale.spec.ts b/packages/docs-ui/src/services/__tests__/doc-view-scale.spec.ts index 25e1e5b69e6a..87ab589f58b8 100644 --- a/packages/docs-ui/src/services/__tests__/doc-view-scale.spec.ts +++ b/packages/docs-ui/src/services/__tests__/doc-view-scale.spec.ts @@ -14,6 +14,10 @@ * limitations under the License. */ +/** + * @vitest-environment jsdom + */ + import { DocumentFlavor, MODERN_DOCUMENT_WIDTH, ModernDocumentWidthMode } from '@univerjs/core'; import { afterEach, describe, expect, it } from 'vitest'; import { DEFAULT_DOC_FIT_TO_WIDTH_OPTIONS } from '../../config/config'; @@ -111,6 +115,20 @@ describe('doc view scale helpers', () => { expect(service.getViewScale()).toBe(1.875); }); + it('falls back to default modern width and zoom while embedded doc units are not resolved', () => { + const service = new DocViewScaleService( + { + engine: { width: 960 }, + unit: null, + } as never, + { getConfig: () => ({ fitToWidth: { mode: 'fit-width', paddingX: 0, minScale: 0 } }) } as never + ); + + expect(service.getBaseWidth()).toBe(MODERN_DOCUMENT_WIDTH[ModernDocumentWidthMode.MEDIUM]); + expect(service.getUserZoomRatio()).toBe(1); + expect(service.getViewScale()).toBe(1); + }); + it('uses configured container width for container-targeted fitting', () => { const context = { engine: { width: 960 }, diff --git a/packages/docs-ui/src/services/__tests__/docs-render.service.spec.ts b/packages/docs-ui/src/services/__tests__/docs-render.service.spec.ts index 570e6c2f3968..e038f2297337 100644 --- a/packages/docs-ui/src/services/__tests__/docs-render.service.spec.ts +++ b/packages/docs-ui/src/services/__tests__/docs-render.service.spec.ts @@ -54,6 +54,10 @@ class TestUniverInstanceService { return this._disposed$.asObservable(); } + getUnitCreateOptions() { + return undefined; + } + addUnit(unit: DocumentDataModel) { this._units.push(unit); this._added$.next({ unit }); @@ -92,29 +96,34 @@ class TestRenderManagerService { readonly createdUnitIds: string[] = []; readonly removedUnitIds: string[] = []; readonly canvases = new Map(); + readonly renderers = new Map TestCanvas; canvasColorService: undefined } }>(); createRender(unitId: string) { this.createdUnitIds.push(unitId); - } - - has(unitId: string) { - return this.createdUnitIds.includes(unitId); - } - - removeRender(unitId: string) { - this.removedUnitIds.push(unitId); - } - - emitCreated(unitId: string) { const canvas = new TestCanvas(); this.canvases.set(unitId, canvas); - this.created$.next({ + const renderer = { unitId, engine: { getCanvas: () => canvas, canvasColorService: undefined, }, - }); + }; + this.renderers.set(unitId, renderer); + return renderer; + } + + has(unitId: string) { + return this.renderers.has(unitId); + } + + getRenderById(unitId: string) { + return this.renderers.get(unitId) ?? null; + } + + removeRender(unitId: string) { + this.removedUnitIds.push(unitId); + this.renderers.delete(unitId); } } @@ -157,7 +166,6 @@ describe('DocsRenderService', () => { it('creates renderers for document lifecycle changes and styles doc canvases', () => { const { service, instanceService, renderManagerService } = createLifecycleService(); - renderManagerService.emitCreated('doc-existing'); expect(renderManagerService.createdUnitIds).toEqual(['doc-existing']); expect(renderManagerService.canvases.get('doc-existing')?.id).toBe('univer-doc-main-canvas'); expect(renderManagerService.canvases.get('doc-existing')?.contextId).toBe('univer-doc-main-canvas'); @@ -168,7 +176,6 @@ describe('DocsRenderService', () => { documentStyle: { documentFlavor: DocumentFlavor.TRADITIONAL }, }); instanceService.addUnit(editorDoc); - renderManagerService.emitCreated(DOCS_NORMAL_EDITOR_UNIT_ID_KEY); expect(renderManagerService.createdUnitIds).toEqual(['doc-existing', DOCS_NORMAL_EDITOR_UNIT_ID_KEY]); expect(renderManagerService.canvases.get(DOCS_NORMAL_EDITOR_UNIT_ID_KEY)?.style.backgroundColor).toBe('transparent'); diff --git a/packages/docs-ui/src/services/doc-embed-integration.service.ts b/packages/docs-ui/src/services/doc-embed-integration.service.ts new file mode 100644 index 000000000000..388eb4a91809 --- /dev/null +++ b/packages/docs-ui/src/services/doc-embed-integration.service.ts @@ -0,0 +1,138 @@ +/** + * 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 type { IDisposable } from '@univerjs/core'; +import { toDisposable } from '@univerjs/core'; +import { Subject } from 'rxjs'; + +export const DOC_EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE = 'data-embed-interaction-boundary-owner'; +export const EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE = DOC_EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE; + +export interface IDocEmbedInteractionBoundaryService { + contains(embedId: string | undefined, target: EventTarget | null | undefined, event?: Event): boolean; + hasRecentInteraction(ownerDocument?: Document): boolean; + hasRecentInteractionFor?(embedId: string | undefined, ownerDocument?: Document): boolean; +} + +export interface IDocEmbedRuntimeFocusCoordinator { + isChildUnitRuntimeEvent(unitId: string | undefined, target: EventTarget | null | undefined, event?: Event): boolean; + isChildUnitInActiveSession(unitId: string | undefined): boolean; + shouldSuppressHostInteraction(unitId: string | undefined, target?: EventTarget | null, event?: Event): boolean; +} + +export class EmbedInteractionBoundaryService implements IDocEmbedInteractionBoundaryService { + private readonly _roots = new Map>(); + + registerOwnedElement(embedId: string, element: HTMLElement): IDisposable { + element.setAttribute(DOC_EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE, embedId); + element.querySelectorAll('*').forEach((child) => child.setAttribute(DOC_EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE, embedId)); + let roots = this._roots.get(embedId); + if (!roots) { + roots = new Set(); + this._roots.set(embedId, roots); + } + roots.add(element); + + return toDisposable(() => { + roots?.delete(element); + element.removeAttribute(DOC_EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE); + element.querySelectorAll('*').forEach((child) => child.removeAttribute(DOC_EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE)); + }); + } + + contains(embedId: string | undefined, target: EventTarget | null | undefined): boolean { + if (!(target instanceof HTMLElement)) { + return false; + } + + if (!embedId) { + return [...this._roots.values()].some((roots) => [...roots].some((root) => root.contains(target))); + } + + return this._roots.get(embedId)?.has(target) === true || + (this._roots.get(embedId) != null && [...this._roots.get(embedId)!].some((root) => root.contains(target))); + } + + hasRecentInteraction(): boolean { + return false; + } + + hasRecentInteractionFor(): boolean { + return false; + } +} + +export const IDocEmbedInteractionBoundaryService = EmbedInteractionBoundaryService; + +export class EmbedRuntimeFocusCoordinator implements IDocEmbedRuntimeFocusCoordinator { + private readonly _leases = new Set<{ embedId?: string; childUnitId?: string; hostUnitId?: string; role: string; owner?: string }>(); + private readonly _elements = new Map>(); + readonly runtimeSessionChanged$ = new Subject(); + + acquireLease(options: { embedId?: string; role: string; owner?: string; childUnitId?: string; hostUnitId?: string }): IDisposable { + this._leases.add(options); + this.runtimeSessionChanged$.next(); + + return toDisposable(() => { + this._leases.delete(options); + this.runtimeSessionChanged$.next(); + }); + } + + registerElement(options: { embedId: string; role: string; element: HTMLElement }): IDisposable { + options.element.setAttribute(DOC_EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE, options.embedId); + let elements = this._elements.get(options.embedId); + if (!elements) { + elements = new Set(); + this._elements.set(options.embedId, elements); + } + elements.add(options.element); + + return toDisposable(() => { + elements?.delete(options.element); + options.element.removeAttribute(DOC_EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE); + }); + } + + registerRuntimeScope(_options: { embedId: string; hostUnitId?: string; childUnitId?: string }): IDisposable { + return toDisposable(() => {}); + } + + isChildUnitRuntimeEvent(_unitId: string | undefined, target: EventTarget | null | undefined): boolean { + return target instanceof HTMLElement && + target.closest(`[${DOC_EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE}]`) != null; + } + + isChildUnitInActiveSession(unitId: string | undefined): boolean { + return [...this._leases].some((lease) => lease.role !== 'runtime' && lease.childUnitId === unitId); + } + + shouldSuppressHostInteraction(unitId: string | undefined, target?: EventTarget | null): boolean { + if (this.isChildUnitRuntimeEvent(unitId, target)) { + return false; + } + + return [...this._leases].some((lease) => { + if (lease.role === 'runtime' || lease.childUnitId === unitId) { + return false; + } + + return lease.hostUnitId === unitId || (!lease.hostUnitId && !lease.childUnitId); + }); + } +} + +export const IDocEmbedRuntimeFocusCoordinator = EmbedRuntimeFocusCoordinator; diff --git a/packages/docs-ui/src/services/doc-popup-manager.service.ts b/packages/docs-ui/src/services/doc-popup-manager.service.ts index eeb7d2a92308..1405e57740bf 100644 --- a/packages/docs-ui/src/services/doc-popup-manager.service.ts +++ b/packages/docs-ui/src/services/doc-popup-manager.service.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import type { INeedCheckDisposable, ITextRangeParam } from '@univerjs/core'; +import type { INeedCheckDisposable, Injector, ITextRangeParam } from '@univerjs/core'; import type { IRichTextEditingMutationParams } from '@univerjs/docs'; import type { BaseObject, Documents, IBoundRectNoAngle, IRender, Scene } from '@univerjs/engine-render'; import type { IPopup } from '@univerjs/ui'; @@ -138,6 +138,15 @@ export class DocCanvasPopManagerService extends Disposable { super(); } + private _shouldUpdateForCommand(commandInfo: { id: string; params?: unknown }, unitId: string): boolean { + if (commandInfo.id !== SetDocZoomRatioOperation.id && commandInfo.id !== RichTextEditingMutation.id) { + return false; + } + + const params = commandInfo.params as { unitId?: string } | undefined; + return params?.unitId == null || params.unitId === unitId; + } + private _createRectPositionObserver(rect: IBoundRectNoAngle | (() => IBoundRectNoAngle), currentRender: IRender) { const calc = () => { const { scene, engine } = currentRender; @@ -170,31 +179,33 @@ export class DocCanvasPopManagerService extends Disposable { const position$ = new BehaviorSubject(position); const disposable = new DisposableCollection(); - - disposable.add(this._commandService.onCommandExecuted((commandInfo) => { - if (commandInfo.id === SetDocZoomRatioOperation.id || commandInfo.id === RichTextEditingMutation.id) { + const updatePosition = () => { + try { const newPosition = calc(); if (newPosition) { position$.next(newPosition); } + } catch { + // The popup may outlive an embedded render while its host switches tabs. + // Keep the last anchor until the popup is disposed. + } + }; + + disposable.add(this._commandService.onCommandExecuted((commandInfo) => { + if (this._shouldUpdateForCommand(commandInfo, currentRender.unitId)) { + updatePosition(); } })); const viewMain = currentRender.scene.getViewport(VIEWPORT_KEY.VIEW_MAIN); if (viewMain) { disposable.add(viewMain.onScrollAfter$.subscribeEvent(() => { - const newPosition = calc(); - if (newPosition) { - position$.next(newPosition); - } + updatePosition(); })); } disposable.add(currentRender.scene.onTransformChange$.subscribeEvent(() => { - const newPosition = calc(); - if (newPosition) { - position$.next(newPosition); - } + updatePosition(); })); return { @@ -268,12 +279,14 @@ export class DocCanvasPopManagerService extends Disposable { if (!currentRender) { throw new Error(`Current render not found, unitId: ${unitId}`); } + const popupInjector = this._resolveEmbeddedPopupInjector(unitId, currentRender); const { position, position$, disposable } = this._createRectPositionObserver(rect, currentRender); const id = this._globalPopupManagerService.addPopup({ ...popup, unitId, subUnitId: 'default', + connectorInjector: popupInjector, anchorRect: position, anchorRect$: position$, canvasElement: currentRender.engine.getCanvasElement(), @@ -301,12 +314,14 @@ export class DocCanvasPopManagerService extends Disposable { if (!currentRender) { throw new Error(`Current render not found, unitId: ${unitId}`); } + const popupInjector = this._resolveEmbeddedPopupInjector(unitId, currentRender); const { position, position$, disposable } = this._createObjectPositionObserver(targetObject, currentRender); const id = this._globalPopupManagerService.addPopup({ ...popup, unitId, subUnitId: 'default', + connectorInjector: popupInjector, anchorRect: position, anchorRect$: position$, canvasElement: currentRender.engine.getCanvasElement(), @@ -341,6 +356,7 @@ export class DocCanvasPopManagerService extends Disposable { if (!currentRender) { throw new Error(`Current render not found, unitId: ${unitId}`); } + const popupInjector = this._resolveEmbeddedPopupInjector(unitId, currentRender); const { positions: bounds, positions$: bounds$, disposable } = this._createRangePositionObserver(range, currentRender); const position$ = bounds$.pipe(map((bounds) => direction.includes('top') ? bounds[0] : bounds[bounds.length - 1])); @@ -349,6 +365,7 @@ export class DocCanvasPopManagerService extends Disposable { ...popup, unitId, subUnitId: 'default', + connectorInjector: popupInjector, anchorRect: direction.includes('top') ? bounds[0] : bounds[bounds.length - 1], anchorRect$: position$, excludeRects: bounds, @@ -371,4 +388,10 @@ export class DocCanvasPopManagerService extends Disposable { }; } // #endregion + + private _resolveEmbeddedPopupInjector(unitId: string, currentRender: IRender): Injector | undefined { + return this._univerInstanceService.getUnitCreateOptions(unitId)?.embeddedRender === true + ? currentRender.getInjector?.() + : undefined; + } } diff --git a/packages/docs-ui/src/services/doc-view-scale.ts b/packages/docs-ui/src/services/doc-view-scale.ts index 8a40ee227efa..98d3615e478e 100644 --- a/packages/docs-ui/src/services/doc-view-scale.ts +++ b/packages/docs-ui/src/services/doc-view-scale.ts @@ -111,10 +111,10 @@ export class DocViewScaleService extends Disposable implements IRenderModule { } getBaseWidth(): number { - const documentStyle = this._context.unit.getSnapshot().documentStyle; + const documentStyle = this._context.unit?.getSnapshot?.()?.documentStyle; return resolveDocFitBaseWidth({ - documentFlavor: documentStyle.documentFlavor, - documentStylePageWidth: documentStyle.pageSize?.width, + documentFlavor: documentStyle?.documentFlavor, + documentStylePageWidth: documentStyle?.pageSize?.width, }); } @@ -133,6 +133,10 @@ export class DocViewScaleService extends Disposable implements IRenderModule { } getUserZoomRatio(): number { + if (this._context.unit == null) { + return 1; + } + return getDocEffectiveZoomRatio(this._context.unit); } diff --git a/packages/docs-ui/src/services/docs-render.service.ts b/packages/docs-ui/src/services/docs-render.service.ts index d140de37b1a9..7343aae6d654 100644 --- a/packages/docs-ui/src/services/docs-render.service.ts +++ b/packages/docs-ui/src/services/docs-render.service.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import type { DocumentDataModel, DocumentFlavor } from '@univerjs/core'; +import type { DocumentDataModel, DocumentFlavor, ICreateUnitOptions } from '@univerjs/core'; import type { ICanvasColorService } from '@univerjs/engine-render'; import { isInternalEditorID, IUniverInstanceService, RxDisposable, UniverInstanceType } from '@univerjs/core'; import { IRenderManagerService } from '@univerjs/engine-render'; @@ -23,8 +23,6 @@ import { resolveDocRenderBackground } from './doc-render-background'; const DOC_MAIN_CANVAS_ID = 'univer-doc-main-canvas'; -export { resolveDocRenderBackground as resolveDocsCanvasBackground } from './doc-render-background'; - export function getDocsCanvasBackgroundColor(documentFlavor?: DocumentFlavor, canvasColorService?: ICanvasColorService, editorBackgroundColor?: string, isEditor?: boolean) { return resolveDocRenderBackground({ documentFlavor, @@ -50,41 +48,53 @@ export class DocsRenderService extends RxDisposable { .subscribe((unitId) => this._createRenderWithId(unitId)); this._instanceSrv.getAllUnitsForType(UniverInstanceType.UNIVER_DOC) - .forEach((documentModel) => this._createRenderer(documentModel)); + .forEach((documentModel) => this._createRenderer(documentModel, this._instanceSrv.getUnitCreateOptions(documentModel.getUnitId()) ?? undefined)); this._instanceSrv.getTypeOfUnitAdded$(UniverInstanceType.UNIVER_DOC) .pipe(takeUntil(this.dispose$)) - .subscribe((event) => this._createRenderer(event.unit)); + .subscribe((event) => this._createRenderer(event.unit, event.options)); this._instanceSrv.getTypeOfUnitDisposed$(UniverInstanceType.UNIVER_DOC) .pipe(takeUntil(this.dispose$)) .subscribe((doc) => this._disposeRenderer(doc)); } - private _createRenderer(doc: DocumentDataModel) { - const unitId = doc.getUnitId(); - this._renderManagerService.created$.subscribe((renderer) => { - if (renderer.unitId === unitId) { - const documentFlavor = doc.getSnapshot().documentStyle.documentFlavor; - const canvas = renderer.engine.getCanvas(); - canvas.setId(DOC_MAIN_CANVAS_ID); - canvas.getContext().setId(DOC_MAIN_CANVAS_ID); - canvas.getCanvasEle().style.backgroundColor = getDocsCanvasBackgroundColor( - documentFlavor, - renderer.engine.canvasColorService, - undefined, - isInternalEditorID(unitId) - ); - } - }); + private _createRenderer(doc: DocumentDataModel, createUnitOptions?: ICreateUnitOptions) { + if (createUnitOptions?.skipAutoRender) { + return; + } + const unitId = doc.getUnitId(); if (!this._renderManagerService.has(unitId)) { - this._createRenderWithId(unitId); + this._createRenderWithId(unitId, doc); + return; + } + + const renderer = this._renderManagerService.getRenderById(unitId); + if (renderer) { + this._syncRendererCanvas(renderer, doc); } } - private _createRenderWithId(unitId: string) { - this._renderManagerService.createRender(unitId); + private _createRenderWithId(unitId: string, doc?: DocumentDataModel) { + const renderer = this._renderManagerService.createRender(unitId); + if (doc) { + this._syncRendererCanvas(renderer, doc); + } + } + + private _syncRendererCanvas(renderer: ReturnType, doc: DocumentDataModel): void { + const unitId = doc.getUnitId(); + const documentFlavor = doc.getSnapshot().documentStyle.documentFlavor; + const canvas = renderer.engine.getCanvas(); + canvas.setId(DOC_MAIN_CANVAS_ID); + canvas.getContext().setId(DOC_MAIN_CANVAS_ID); + canvas.getCanvasEle().style.backgroundColor = getDocsCanvasBackgroundColor( + documentFlavor, + renderer.engine.canvasColorService, + undefined, + isInternalEditorID(unitId) + ); } private _disposeRenderer(doc: DocumentDataModel) { diff --git a/packages/docs-ui/src/services/float-menu.service.ts b/packages/docs-ui/src/services/float-menu.service.ts index 1d66bcbe20db..e96d7445f022 100644 --- a/packages/docs-ui/src/services/float-menu.service.ts +++ b/packages/docs-ui/src/services/float-menu.service.ts @@ -78,6 +78,10 @@ export class DocFloatMenuService extends Disposable implements IRenderModule { return this._floatMenu; } + hideFloatMenu(): void { + this._hideFloatMenu(); + } + private _registerFloatMenu() { this.disposeWithMe(this._componentManager.register(FLOAT_MENU_COMPONENT_KEY, FloatToolbar)); } diff --git a/packages/docs-ui/src/services/selection/__tests__/convert-text-range.spec.ts b/packages/docs-ui/src/services/selection/__tests__/convert-text-range.spec.ts index 280702b694c5..76f3a62c6f4d 100644 --- a/packages/docs-ui/src/services/selection/__tests__/convert-text-range.spec.ts +++ b/packages/docs-ui/src/services/selection/__tests__/convert-text-range.spec.ts @@ -15,6 +15,7 @@ */ import type { INodePosition } from '@univerjs/engine-render'; +import { DataStreamTreeTokenType } from '@univerjs/core'; import { DocumentSkeletonPageType, setDocsTableRenderViewportProvider } from '@univerjs/engine-render'; import { afterEach, describe, expect, it } from 'vitest'; import { @@ -613,4 +614,118 @@ describe('selection convert text range helpers', () => { top: 0, }); }); + + it('uses normal caret height for non-inline embed custom blocks', () => { + const { position, skeleton } = createEmbedCustomBlockCursorHarness(); + const convertor = new NodePositionConvertToCursor({ + docsLeft: 0, + docsTop: 0, + pageLayoutType: 0, + pageMarginLeft: 0, + pageMarginTop: 0, + } as never, skeleton as never); + + const result = convertor.getRangePointData(position, position); + + expect(getAnchorBounding(result.contentBoxPointGroup).height).toBeLessThan(30); + }); + + it('does not draw text selection rectangles for non-inline embed custom blocks', () => { + const { position, skeleton } = createEmbedCustomBlockCursorHarness(); + const convertor = new NodePositionConvertToCursor({ + docsLeft: 0, + docsTop: 0, + pageLayoutType: 0, + pageMarginLeft: 0, + pageMarginTop: 0, + } as never, skeleton as never); + + const result = convertor.getRangePointData( + { ...position, isBack: true }, + { ...position, isBack: false } + ); + + expect(result.borderBoxPointGroup).toHaveLength(0); + expect(result.contentBoxPointGroup).toHaveLength(0); + }); }); + +function createEmbedCustomBlockCursorHarness() { + const drawingId = 'embed-block-1'; + const glyph = { + bBox: { ba: 480, bd: 0 }, + count: 1, + drawingId, + fontStyle: { fontSize: 14 }, + glyphType: 'PLACEHOLDER', + left: 0, + streamType: DataStreamTreeTokenType.CUSTOM_BLOCK, + width: 720, + }; + const line = { + asc: 13, + contentHeight: 480, + divides: [{ + glyphGroup: [glyph], + left: 0, + paddingLeft: 0, + st: 0, + }], + lineHeight: 480, + marginBottom: 0, + marginTop: 0, + paddingBottom: 0, + paddingTop: 0, + top: 0, + }; + const page = { + marginLeft: 0, + marginTop: 0, + pageHeight: 1000, + pageWidth: 1000, + sections: [{ + columns: [{ + left: 0, + lines: [line], + }], + top: 0, + }], + }; + const skeleton = { + getSkeletonData: () => ({ + pages: [page], + skeFooters: new Map(), + skeHeaders: new Map(), + }), + getViewModel: () => ({ + getDataModel: () => ({ + getSnapshot: () => ({ + drawings: { + [drawingId]: { + data: { + version: 1, + embedId: 'embed-1', + hostAnchorId: drawingId, + interactionMode: 'block', + }, + }, + }, + }), + }), + }), + }; + const position: INodePosition = { + column: 0, + divide: 0, + glyph: 0, + isBack: false, + line: 0, + page: 0, + pageType: DocumentSkeletonPageType.BODY, + path: ['pages', 0], + section: 0, + segmentPage: -1, + }; + + return { position, skeleton }; +} diff --git a/packages/docs-ui/src/services/selection/__tests__/doc-selection-render.service.spec.ts b/packages/docs-ui/src/services/selection/__tests__/doc-selection-render.service.spec.ts index 0fc4999084d4..f7292ac99402 100644 --- a/packages/docs-ui/src/services/selection/__tests__/doc-selection-render.service.spec.ts +++ b/packages/docs-ui/src/services/selection/__tests__/doc-selection-render.service.spec.ts @@ -14,10 +14,13 @@ * limitations under the License. */ +// @vitest-environment jsdom + import type { IDisposable, IDocumentData } from '@univerjs/core'; import type { Mock } from 'vitest'; -import { DataStreamTreeTokenType, DOC_RANGE_TYPE, Univer, UniverInstanceType } from '@univerjs/core'; +import { DataStreamTreeTokenType, DOC_RANGE_TYPE, DOCS_NORMAL_EDITOR_UNIT_ID_KEY, Univer, UniverInstanceType } from '@univerjs/core'; import { DocSkeletonManagerService } from '@univerjs/docs'; +import { EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE, EmbedInteractionBoundaryService, EmbedRuntimeFocusCoordinator } from '../../doc-embed-integration.service'; import { GlyphType, RenderUnit } from '@univerjs/engine-render'; import { ILayoutService } from '@univerjs/ui'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -264,6 +267,8 @@ class TestRenderEvent { } function createRealSelectionRenderService(options: { + embedInteractionBoundaryService?: Partial; + embedRuntimeFocusCoordinator?: EmbedRuntimeFocusCoordinator; mainComponent?: unknown; scene?: unknown; } = {}) { @@ -272,6 +277,12 @@ function createRealSelectionRenderService(options: { const univer = new Univer(); const injector = univer.__getInjector(); injector.add([ILayoutService, { useClass: TestLayoutService as never }]); + if (options.embedInteractionBoundaryService) { + injector.add([EmbedInteractionBoundaryService, { useValue: options.embedInteractionBoundaryService as never }]); + } + if (options.embedRuntimeFocusCoordinator) { + injector.add([EmbedRuntimeFocusCoordinator, { useValue: options.embedRuntimeFocusCoordinator }]); + } const documentData: IDocumentData = { id: 'selection-render-doc', body: { @@ -757,6 +768,239 @@ describe('DocSelectionRenderService', () => { expect(TestLayoutService.registeredElements).toEqual([]); }); + it('does not steal focus back from an embedded runtime during selection sync', () => { + const embedCanvas = document.createElement('canvas'); + embedCanvas.tabIndex = 0; + document.body.appendChild(embedCanvas); + const focusCoordinator = new EmbedRuntimeFocusCoordinator(); + const embedInteractionBoundaryService = { + contains: vi.fn((_embedId: string | undefined, target: EventTarget | null | undefined) => target === embedCanvas), + hasRecentInteraction: vi.fn(() => false), + hasRecentInteractionFor: vi.fn(() => false), + }; + const { input, renderUnit, service, univer } = createRealSelectionRenderService({ + embedInteractionBoundaryService, + embedRuntimeFocusCoordinator: focusCoordinator, + }); + cleanup.push(() => renderUnit.dispose(), () => univer.dispose()); + + embedCanvas.focus(); + const lease = focusCoordinator.acquireLease({ + embedId: 'embed-1', + role: 'child-session', + owner: 'stage2-runtime', + hostUnitId: 'selection-render-doc', + }); + cleanup.push(() => lease.dispose()); + + service.sync(); + + expect(document.activeElement).toBe(embedCanvas); + expect(document.activeElement).not.toBe(input); + expect(embedInteractionBoundaryService.contains).not.toHaveBeenCalledWith(undefined, embedCanvas); + }); + + it('keeps normal host document selection focus when an unrelated embed boundary exists', () => { + const embedCanvas = document.createElement('canvas'); + embedCanvas.tabIndex = 0; + document.body.appendChild(embedCanvas); + const embedInteractionBoundaryService = { + contains: vi.fn((_embedId: string | undefined, target: EventTarget | null | undefined) => target === embedCanvas), + hasRecentInteraction: vi.fn(() => true), + hasRecentInteractionFor: vi.fn(() => false), + }; + const { input, renderUnit, service, univer } = createRealSelectionRenderService({ embedInteractionBoundaryService }); + cleanup.push(() => renderUnit.dispose(), () => univer.dispose()); + + embedCanvas.focus(); + service.sync(); + + expect(document.activeElement).toBe(input); + }); + + it('does not steal focus from an embed-owned child element inside the Univer layout root', () => { + const focusCoordinator = new EmbedRuntimeFocusCoordinator(); + const embedInteractionBoundaryService = { + contains: vi.fn(() => true), + hasRecentInteraction: vi.fn(() => false), + hasRecentInteractionFor: vi.fn(() => false), + }; + const { input, renderUnit, service, univer } = createRealSelectionRenderService({ + embedInteractionBoundaryService, + embedRuntimeFocusCoordinator: focusCoordinator, + }); + cleanup.push(() => renderUnit.dispose(), () => univer.dispose()); + + const runtimeCanvas = document.createElement('canvas'); + runtimeCanvas.tabIndex = 0; + TestLayoutService.root.appendChild(runtimeCanvas); + + runtimeCanvas.focus(); + const lease = focusCoordinator.acquireLease({ + embedId: 'embed-1', + role: 'child-session', + owner: 'stage2-runtime', + hostUnitId: 'selection-render-doc', + }); + cleanup.push(() => lease.dispose()); + + service.sync(); + + expect(document.activeElement).toBe(runtimeCanvas); + expect(document.activeElement).not.toBe(input); + }); + + it('does not steal focus back while an embedded child editor owns an interaction lease', () => { + const focusCoordinator = new EmbedRuntimeFocusCoordinator(); + const { input, renderUnit, service, univer } = createRealSelectionRenderService({ + embedInteractionBoundaryService: { + contains: vi.fn(() => false), + hasRecentInteraction: vi.fn(() => false), + }, + embedRuntimeFocusCoordinator: focusCoordinator, + }); + cleanup.push(() => renderUnit.dispose(), () => univer.dispose()); + + TestLayoutService.root.tabIndex = 0; + TestLayoutService.root.focus(); + const lease = focusCoordinator.acquireLease({ + embedId: 'embed-1', + role: 'child-editor', + owner: 'sheet-cell-editor', + }); + cleanup.push(() => lease.dispose()); + + service.sync(); + + expect(document.activeElement).toBe(TestLayoutService.root); + expect(document.activeElement).not.toBe(input); + }); + + it('keeps host document focus suspended during a plain stage2 child session', () => { + const focusCoordinator = new EmbedRuntimeFocusCoordinator(); + const { input, renderUnit, service, univer } = createRealSelectionRenderService({ + embedInteractionBoundaryService: { + contains: vi.fn(() => false), + hasRecentInteraction: vi.fn(() => false), + }, + embedRuntimeFocusCoordinator: focusCoordinator, + }); + cleanup.push(() => renderUnit.dispose(), () => univer.dispose()); + + const lease = focusCoordinator.acquireLease({ + embedId: 'embed-1', + role: 'child-session', + owner: 'stage2-runtime', + }); + cleanup.push(() => lease.dispose()); + + input.blur(); + expect(document.activeElement).toBe(document.body); + + service.sync(); + + expect(document.activeElement).toBe(document.body); + expect(document.activeElement).not.toBe(input); + }); + + it('does not force-focus the host hidden editor while a child session owns the host document', () => { + const focusCoordinator = new EmbedRuntimeFocusCoordinator(); + const { input, renderUnit, service, univer } = createRealSelectionRenderService({ + embedInteractionBoundaryService: { + contains: vi.fn(() => false), + hasRecentInteraction: vi.fn(() => false), + }, + embedRuntimeFocusCoordinator: focusCoordinator, + }); + cleanup.push(() => renderUnit.dispose(), () => univer.dispose()); + + const lease = focusCoordinator.acquireLease({ + embedId: 'embed-1', + role: 'child-session', + owner: 'stage2-runtime', + hostUnitId: 'selection-render-doc', + childUnitId: 'child-sheet', + }); + cleanup.push(() => lease.dispose()); + + input.blur(); + expect(document.activeElement).toBe(document.body); + + service.activate(12, 34, true); + + expect(document.activeElement).toBe(document.body); + expect(document.activeElement).not.toBe(input); + }); + + it('does not treat its own embedded runtime as external focus while a child editor lease is active', () => { + const focusCoordinator = new EmbedRuntimeFocusCoordinator(); + const { input, renderUnit, service, univer } = createRealSelectionRenderService({ + embedInteractionBoundaryService: { + contains: vi.fn(() => true), + hasRecentInteraction: vi.fn(() => false), + }, + embedRuntimeFocusCoordinator: focusCoordinator, + }); + cleanup.push(() => renderUnit.dispose(), () => univer.dispose()); + + TestLayoutService.root.setAttribute(EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE, 'embed-1'); + const runtimeCanvas = document.createElement('canvas'); + runtimeCanvas.tabIndex = 0; + runtimeCanvas.setAttribute(EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE, 'embed-1'); + input.setAttribute(EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE, 'embed-1'); + input.parentElement?.setAttribute(EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE, 'embed-1'); + TestLayoutService.root.appendChild(runtimeCanvas); + runtimeCanvas.focus(); + const lease = focusCoordinator.acquireLease({ + embedId: 'embed-1', + role: 'child-editor', + owner: 'sheet-cell-editor', + }); + cleanup.push(() => lease.dispose()); + + expect(service.canFocusing).toBe(true); + + service.sync(); + + expect(document.activeElement).toBe(input); + }); + + it('allows the internal sheet cell editor to refocus from an embed-owned canvas', () => { + const focusCoordinator = new EmbedRuntimeFocusCoordinator(); + const { input, renderUnit, service, univer } = createRealSelectionRenderService({ + embedInteractionBoundaryService: { + contains: vi.fn(() => true), + hasRecentInteraction: vi.fn(() => false), + }, + embedRuntimeFocusCoordinator: focusCoordinator, + }); + cleanup.push(() => renderUnit.dispose(), () => univer.dispose()); + + const runtimeCanvas = document.createElement('canvas'); + runtimeCanvas.tabIndex = 0; + runtimeCanvas.setAttribute(EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE, 'embed-1'); + input.setAttribute(EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE, 'embed-1'); + input.parentElement?.setAttribute(EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE, 'embed-1'); + TestLayoutService.root.appendChild(runtimeCanvas); + runtimeCanvas.focus(); + (service as unknown as { _context: { unitId: string } })._context.unitId = DOCS_NORMAL_EDITOR_UNIT_ID_KEY; + const lease = focusCoordinator.acquireLease({ + embedId: 'embed-1', + role: 'child-editor', + owner: 'sheet-cell-editor', + }); + cleanup.push(() => lease.dispose()); + + expect(input.closest(`[${EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE}]`)?.getAttribute(EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE)).toBe('embed-1'); + expect(runtimeCanvas.closest(`[${EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE}]`)?.getAttribute(EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE)).toBe('embed-1'); + expect((service as unknown as { _containsCurrentEmbedRuntimeElement(element: HTMLElement): boolean })._containsCurrentEmbedRuntimeElement(runtimeCanvas)).toBe(true); + expect(service.canFocusing).toBe(true); + + service.activate(12, 34); + + expect(document.activeElement).toBe(input); + }); + it('publishes hidden editor input, paste, focus, and blur events with the typed content', () => { const { input, renderUnit, service, univer } = createRealSelectionRenderService(); cleanup.push(() => renderUnit.dispose(), () => univer.dispose()); @@ -792,6 +1036,80 @@ describe('DocSelectionRenderService', () => { expect(input.textContent).toBe(''); }); + it('does not publish host hidden editor events while a child session owns the host document', () => { + const focusCoordinator = new EmbedRuntimeFocusCoordinator(); + const { input, renderUnit, service, univer } = createRealSelectionRenderService({ + embedInteractionBoundaryService: { + contains: vi.fn(() => false), + hasRecentInteraction: vi.fn(() => false), + }, + embedRuntimeFocusCoordinator: focusCoordinator, + }); + cleanup.push(() => renderUnit.dispose(), () => univer.dispose()); + const received: string[] = []; + const subscriptions = [ + service.onInputBefore$.subscribe(() => received.push('before-input')), + service.onInput$.subscribe(() => received.push('input')), + service.onKeydown$.subscribe(() => received.push('keydown')), + service.onBlur$.subscribe(() => received.push('blur')), + ]; + cleanup.push(() => { + for (const subscription of subscriptions) { + subscription.unsubscribe(); + } + }); + + input.focus(); + const lease = focusCoordinator.acquireLease({ + embedId: 'docs-custom-block-sheet', + role: 'child-session', + owner: 'doc-block-stage2-runtime', + hostUnitId: 'selection-render-doc', + childUnitId: 'child-sheet', + }); + cleanup.push(() => lease.dispose()); + + input.textContent = '='; + input.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: '=' })); + input.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: '=' })); + input.textContent = 'leaving host editor'; + input.dispatchEvent(new Event('blur', { bubbles: true })); + + expect(received).toEqual([]); + expect(input.textContent).toBe('leaving host editor'); + }); + + it('blurs the host hidden editor when it is focused during a child session', () => { + const focusCoordinator = new EmbedRuntimeFocusCoordinator(); + const { input, renderUnit, univer } = createRealSelectionRenderService({ + embedInteractionBoundaryService: { + contains: vi.fn(() => false), + hasRecentInteraction: vi.fn(() => false), + }, + embedRuntimeFocusCoordinator: focusCoordinator, + }); + cleanup.push(() => renderUnit.dispose(), () => univer.dispose()); + + input.tabIndex = 0; + input.focus(); + expect(document.activeElement).toBe(input); + input.blur(); + expect(document.activeElement).not.toBe(input); + + const lease = focusCoordinator.acquireLease({ + embedId: 'docs-custom-block-sheet', + role: 'child-session', + owner: 'doc-block-stage2-runtime', + hostUnitId: 'selection-render-doc', + childUnitId: 'child-sheet', + }); + cleanup.push(() => lease.dispose()); + + input.focus(); + + expect(document.activeElement).not.toBe(input); + }); + it('suppresses normal keydown while IME composition is active, then resumes key events after composition ends', () => { const { input, renderUnit, service, univer } = createRealSelectionRenderService(); cleanup.push(() => renderUnit.dispose(), () => univer.dispose()); @@ -838,6 +1156,31 @@ describe('DocSelectionRenderService', () => { ]); }); + it('keeps embed-owned select-all keydown inside the child editor without blocking editor input handling', () => { + const { input, renderUnit, service, univer } = createRealSelectionRenderService(); + cleanup.push(() => renderUnit.dispose(), () => univer.dispose()); + input.setAttribute(EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE, 'embed-1'); + const documentKeydown = vi.fn(); + const keydownEvents: Array<{ content?: string; defaultPrevented: boolean }> = []; + document.addEventListener('keydown', documentKeydown); + const subscription = service.onKeydown$.subscribe((config) => { + keydownEvents.push({ + content: config.content, + defaultPrevented: config.event.defaultPrevented, + }); + }); + cleanup.push( + () => document.removeEventListener('keydown', documentKeydown), + () => subscription.unsubscribe() + ); + + input.textContent = 'cell text'; + input.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'a', metaKey: true })); + + expect(keydownEvents).toEqual([{ content: 'cell text', defaultPrevented: false }]); + expect(documentKeydown).not.toHaveBeenCalled(); + }); + it('keeps segment state stable while the editor is reused by header, footer, and body selection flows', () => { const { renderUnit, service, univer } = createRealSelectionRenderService(); cleanup.push(() => renderUnit.dispose(), () => univer.dispose()); diff --git a/packages/docs-ui/src/services/selection/convert-text-range.ts b/packages/docs-ui/src/services/selection/convert-text-range.ts index da45c2e04fcf..9e5369e21df0 100644 --- a/packages/docs-ui/src/services/selection/convert-text-range.ts +++ b/packages/docs-ui/src/services/selection/convert-text-range.ts @@ -29,6 +29,8 @@ import type { INodePosition, IPoint, } from '@univerjs/engine-render'; +import { DataStreamTreeTokenType } from '@univerjs/core'; +import { shouldUseInlineTextSelectionForDocsCustomBlockDrawing } from '@univerjs/docs'; import { compareDocumentSkeletonNestedPagePathOrder, DocumentSkeletonPageType, @@ -312,7 +314,12 @@ export class NodePositionConvertToCursor { const isEndBack = end.glyph === end_sp && isLast ? end.isBack : false; const collapsed = start === end; - const anchorGlyph = isStartBack ? (preGlyph ?? firstGlyph) : firstGlyph; + const rawAnchorGlyph = isStartBack ? (preGlyph ?? firstGlyph) : firstGlyph; + const anchorGlyph = this._getCaretGlyph(rawAnchorGlyph, glyphGroup, start_sp); + const selectedGlyphs = glyphGroup.slice(start_sp, end_sp + 1); + const isSelectionOnlyNonInlineEmbedBlock = !collapsed && + selectedGlyphs.length > 0 && + selectedGlyphs.every((glyph) => this._isNonInlineEmbedCustomBlockGlyph(glyph)); const borderBoxStartY = startY; const borderBoxEndY = contentHeight == null ? startY + lineHeight - marginTop - marginBottom @@ -353,10 +360,10 @@ export class NodePositionConvertToCursor { const clippedBorderBoxPosition = clipPositionToHorizontalRange(borderBoxPosition, this._horizontalClip); const clippedContentBoxPosition = clipPositionToHorizontalRange(contentBoxPosition, this._horizontalClip); - if (clippedBorderBoxPosition) { + if (clippedBorderBoxPosition && !isSelectionOnlyNonInlineEmbedBlock) { borderBoxPointGroup.push(pushToPoints(clippedBorderBoxPosition)); } - if (clippedContentBoxPosition) { + if (clippedContentBoxPosition && !isSelectionOnlyNonInlineEmbedBlock) { contentBoxPointGroup.push(pushToPoints(clippedContentBoxPosition)); } @@ -374,6 +381,44 @@ export class NodePositionConvertToCursor { }; } + private _getCaretGlyph( + glyph: IDocumentSkeletonGlyph | undefined, + glyphGroup: IDocumentSkeletonGlyph[], + glyphIndex: number + ): IDocumentSkeletonGlyph { + if (!glyph || !this._isNonInlineEmbedCustomBlockGlyph(glyph)) { + return glyph!; + } + + const neighbor = this._findTextLikeGlyph(glyphGroup, glyphIndex - 1, -1) ?? + this._findTextLikeGlyph(glyphGroup, glyphIndex + 1, 1); + + return neighbor ?? { + ...glyph, + bBox: getDefaultCaretBoundingBox(glyph), + }; + } + + private _findTextLikeGlyph(glyphGroup: IDocumentSkeletonGlyph[], startIndex: number, step: 1 | -1): IDocumentSkeletonGlyph | undefined { + for (let index = startIndex; index >= 0 && index < glyphGroup.length; index += step) { + const glyph = glyphGroup[index]; + if (!this._isNonInlineEmbedCustomBlockGlyph(glyph)) { + return glyph; + } + } + + return undefined; + } + + private _isNonInlineEmbedCustomBlockGlyph(glyph: IDocumentSkeletonGlyph | undefined): boolean { + if (!glyph?.drawingId || glyph.streamType !== DataStreamTreeTokenType.CUSTOM_BLOCK) { + return false; + } + + const drawing = this._docSkeleton.getViewModel?.().getDataModel?.().getSnapshot?.().drawings?.[glyph.drawingId]; + return !shouldUseInlineTextSelectionForDocsCustomBlockDrawing(drawing); + } + private _isValidPosition(startOrigin: INodePosition, endOrigin: INodePosition) { const { segmentPage: startPage, pageType: startPageType } = startOrigin; const { segmentPage: endPage, pageType: endPageType } = endOrigin; @@ -801,6 +846,20 @@ function getCellPageFromSegmentPath( return null; } +function getDefaultCaretBoundingBox(glyph: IDocumentSkeletonGlyph): IDocumentSkeletonGlyph['bBox'] { + const fontSize = getGlyphFontSize(glyph); + return { + ...glyph.bBox, + ba: fontSize, + bd: Math.max(2, Math.ceil(fontSize * 0.25)), + }; +} + +function getGlyphFontSize(glyph: IDocumentSkeletonGlyph): number { + const fontSize = glyph.fontStyle?.originFontSize ?? glyph.fontStyle?.fontSize ?? glyph.ts?.fs; + return typeof fontSize === 'number' && Number.isFinite(fontSize) && fontSize > 0 ? fontSize : 14; +} + function getDocumentUnitId(docSkeleton: DocumentSkeleton): string { const viewModel = docSkeleton.getViewModel() as { getDataModel?: () => { diff --git a/packages/docs-ui/src/services/selection/doc-selection-render.service.ts b/packages/docs-ui/src/services/selection/doc-selection-render.service.ts index 808d2d0ba5db..5439773d54b1 100644 --- a/packages/docs-ui/src/services/selection/doc-selection-render.service.ts +++ b/packages/docs-ui/src/services/selection/doc-selection-render.service.ts @@ -33,11 +33,12 @@ import type { } from '@univerjs/engine-render'; import type { Subscription } from 'rxjs'; import type { RectRange } from './rect-range'; -import { DataStreamTreeTokenType, DOC_RANGE_TYPE, ILogService, Inject, IUniverInstanceService, RxDisposable, UniverInstanceType } from '@univerjs/core'; +import { DataStreamTreeTokenType, DOC_RANGE_TYPE, ILogService, Inject, isInternalEditorID, IUniverInstanceService, Optional, RxDisposable, UniverInstanceType } from '@univerjs/core'; import { DocSkeletonManagerService } from '@univerjs/docs'; import { CURSOR_TYPE, getSystemHighlightColor, GlyphType, NORMAL_TEXT_SELECTION_PLUGIN_STYLE, PageLayoutType, ScrollTimer, Vector2 } from '@univerjs/engine-render'; import { ILayoutService, KeyCode } from '@univerjs/ui'; import { BehaviorSubject, filter, fromEvent, merge, Subject, takeUntil } from 'rxjs'; +import { DOC_EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE, IDocEmbedInteractionBoundaryService, IDocEmbedRuntimeFocusCoordinator } from '../doc-embed-integration.service'; import { compareNodePositionLogic } from './convert-text-range'; import { getCanvasOffsetByEngine, @@ -140,11 +141,18 @@ export class DocSelectionRenderService extends RxDisposable implements IRenderMo } get isFocusing() { - return this._input === document.activeElement; + return this._input === this._getOwnerDocument().activeElement; } get canFocusing() { - return this.isFocusing || document.activeElement === document.body || document.activeElement === null; + const ownerDocument = this._getOwnerDocument(); + const activeElement = ownerDocument.activeElement; + return !this._shouldPreserveExternalFocus() && ( + this.isFocusing || + activeElement === ownerDocument.body || + activeElement === null || + (activeElement instanceof HTMLElement && this._containsCurrentEmbedRuntimeElement(activeElement)) + ); } constructor( @@ -152,7 +160,9 @@ export class DocSelectionRenderService extends RxDisposable implements IRenderMo @ILayoutService private readonly _layoutService: ILayoutService, @ILogService private readonly _logService: ILogService, @IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService, - @Inject(DocSkeletonManagerService) private readonly _docSkeletonManagerService: DocSkeletonManagerService + @Inject(DocSkeletonManagerService) private readonly _docSkeletonManagerService: DocSkeletonManagerService, + @Optional(IDocEmbedInteractionBoundaryService) private readonly _embedInteractionBoundaryService?: IDocEmbedInteractionBoundaryService, + @Optional(IDocEmbedRuntimeFocusCoordinator) private readonly _embedRuntimeFocusCoordinator?: IDocEmbedRuntimeFocusCoordinator ) { super(); this._initDOM(); @@ -372,16 +382,19 @@ export class DocSelectionRenderService extends RxDisposable implements IRenderMo this._container.style.top = `${top}px`; this._container.style.zIndex = '1000'; - if (this.canFocusing || force) { + if ((force && !this._shouldPreserveExternalFocus()) || (!force && this.canFocusing)) { this.focus(); } } hasFocus(): boolean { - return document.activeElement === this._input; + return this._getOwnerDocument().activeElement === this._input; } focus(): void { + if (!this._input.hasAttribute('tabindex')) { + this._input.tabIndex = -1; + } this._input.focus(); } @@ -764,6 +777,10 @@ export class DocSelectionRenderService extends RxDisposable implements IRenderMo } } + private _getOwnerDocument(): Document { + return this._container?.ownerDocument ?? document; + } + private _getNodePosition(node: Nullable): Nullable { if (node == null) { return; @@ -982,6 +999,9 @@ export class DocSelectionRenderService extends RxDisposable implements IRenderMo const anchor = activeRangeInstance?.getAnchor(); if (!anchor || (anchor && !anchor.visible) || this.activeViewPort == null) { + if (this._shouldPreserveExternalFocus()) { + return; + } this.focus(); return; } @@ -1178,10 +1198,14 @@ export class DocSelectionRenderService extends RxDisposable implements IRenderMo private _initInputEvents() { this.disposeWithMe( fromEvent(this._input, 'keydown').subscribe((e) => { + if (this._shouldSuppressHostHiddenEditorEvent(e)) { + return; + } if (this._isIMEInputApply) { return; } + this._stopEmbedOwnedEditorShortcutPropagation(e); this._eventHandle(e, (config) => { this._onKeydown$.next(config); }); @@ -1190,6 +1214,9 @@ export class DocSelectionRenderService extends RxDisposable implements IRenderMo this.disposeWithMe( fromEvent(this._input, 'input').subscribe((e) => { + if (this._shouldSuppressHostHiddenEditorEvent(e)) { + return; + } // Prevent input when there is any rect ranges. if ((e as InputEvent).inputType === 'historyUndo' || (e as InputEvent).inputType === 'historyRedo') { return; @@ -1212,6 +1239,9 @@ export class DocSelectionRenderService extends RxDisposable implements IRenderMo this.disposeWithMe( fromEvent(this._input, 'compositionstart').subscribe((e) => { + if (this._shouldSuppressHostHiddenEditorEvent(e)) { + return; + } // Prevent input when there is any rect ranges. if (this._rectRangeList.length > 0) { e.stopPropagation(); @@ -1228,6 +1258,9 @@ export class DocSelectionRenderService extends RxDisposable implements IRenderMo this.disposeWithMe( fromEvent(this._input, 'compositionend').subscribe((e) => { + if (this._shouldSuppressHostHiddenEditorEvent(e)) { + return; + } this._isIMEInputApply = false; this._eventHandle(e, (config) => { @@ -1238,6 +1271,9 @@ export class DocSelectionRenderService extends RxDisposable implements IRenderMo this.disposeWithMe( fromEvent(this._input, 'compositionupdate').subscribe((e) => { + if (this._shouldSuppressHostHiddenEditorEvent(e)) { + return; + } this._eventHandle(e, (config) => { this._onInputBefore$.next(config); this._onCompositionupdate$.next(config); @@ -1247,6 +1283,9 @@ export class DocSelectionRenderService extends RxDisposable implements IRenderMo this.disposeWithMe( fromEvent(this._input, 'paste').subscribe((e) => { + if (this._shouldSuppressHostHiddenEditorEvent(e)) { + return; + } this._eventHandle(e, (config) => { this._onPaste$.next(config); }); @@ -1255,6 +1294,9 @@ export class DocSelectionRenderService extends RxDisposable implements IRenderMo this.disposeWithMe( fromEvent(this._input, 'focus').subscribe((e) => { + if (this._shouldSuppressHostHiddenEditorEvent(e)) { + return; + } this._eventHandle(e, (config) => { this._onFocus$.next(config); }); @@ -1271,6 +1313,9 @@ export class DocSelectionRenderService extends RxDisposable implements IRenderMo this.disposeWithMe( fromEvent(this._input, 'blur').subscribe((e) => { + if (this._shouldSuppressHostHiddenEditorEvent(e)) { + return; + } this._eventHandle(e, (config) => { this._onBlur$.next(config); }); @@ -1294,6 +1339,52 @@ export class DocSelectionRenderService extends RxDisposable implements IRenderMo }); } + private _shouldSuppressHostHiddenEditorEvent(event: Event): boolean { + const unitId = this._context.unitId; + if (isInternalEditorID(unitId)) { + return false; + } + + if (this._embedRuntimeFocusCoordinator?.isChildUnitRuntimeEvent(unitId, event.target, event)) { + return false; + } + + if (this._embedRuntimeFocusCoordinator?.isChildUnitInActiveSession(unitId)) { + return false; + } + + if (event.target instanceof HTMLElement && this._containsCurrentEmbedRuntimeElement(event.target)) { + return false; + } + + if (this._embedRuntimeFocusCoordinator?.shouldSuppressHostInteraction(unitId, event.target, event)) { + event.stopPropagation(); + if (event.cancelable) { + event.preventDefault(); + } + if (event.type === 'focus' && event.target instanceof HTMLElement) { + event.target.blur(); + } + return true; + } + + return false; + } + + private _stopEmbedOwnedEditorShortcutPropagation(event: Event): void { + if (!(event instanceof KeyboardEvent) || !this._getCurrentEmbedOwner()) { + return; + } + + const key = event.key.toLowerCase(); + const isSelectAllShortcut = key === 'a' && (event.metaKey || event.ctrlKey); + if (!isSelectAllShortcut) { + return; + } + + event.stopPropagation(); + } + private _getTransformCoordForDocumentOffset(evtOffsetX: number, evtOffsetY: number) { const document = this._context.mainComponent as Documents; const { documentTransform } = document.getOffsetConfig(); @@ -1338,6 +1429,93 @@ export class DocSelectionRenderService extends RxDisposable implements IRenderMo return nodeInfo; } + private _shouldPreserveExternalFocus(): boolean { + const ownerDocument = this._getOwnerDocument(); + const activeElement = ownerDocument.activeElement; + const currentEmbedOwner = this._getCurrentEmbedOwner(); + if (this._embedRuntimeFocusCoordinator?.isChildUnitInActiveSession(this._context.unitId)) { + return false; + } + + if (this._embedRuntimeFocusCoordinator?.isChildUnitRuntimeEvent(this._context.unitId, activeElement)) { + return false; + } + + if (activeElement instanceof HTMLElement && this._containsOwnEditorElement(activeElement)) { + return false; + } + + if (activeElement instanceof HTMLElement && this._containsCurrentEmbedRuntimeElement(activeElement)) { + return false; + } + + if (this._embedRuntimeFocusCoordinator?.shouldSuppressHostInteraction(this._context.unitId, activeElement)) { + return true; + } + + if (currentEmbedOwner && this._embedInteractionBoundaryService?.contains(currentEmbedOwner, activeElement)) { + return true; + } + + if (activeElement instanceof HTMLElement && this._containsCurrentLayoutElement(activeElement)) { + return false; + } + + return this._embedInteractionBoundaryService?.hasRecentInteractionFor?.(currentEmbedOwner, ownerDocument) === true; + } + + private _containsCurrentEmbedRuntimeElement(element: HTMLElement): boolean { + const currentEmbedOwner = this._getCurrentEmbedOwner(); + if (!currentEmbedOwner) { + return false; + } + + const activeEmbedOwner = this._getElementEmbedOwner(element); + return activeEmbedOwner === currentEmbedOwner && this._containsCurrentLayoutElement(element); + } + + private _getCurrentEmbedOwner(): string | undefined { + const rootContainerElement = this._layoutService?.rootContainerElement; + return this._getElementEmbedOwner(this._input) ?? + this._getElementEmbedOwner(this._container) ?? + this._getElementEmbedOwner(rootContainerElement instanceof HTMLElement ? rootContainerElement : undefined); + } + + private _getElementEmbedOwner(element: HTMLElement | null | undefined): string | undefined { + if (!element || typeof element.closest !== 'function') { + return undefined; + } + + return element.closest(`[${DOC_EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE}]`)?.getAttribute(DOC_EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE) ?? undefined; + } + + private _containsOwnEditorElement(element: HTMLElement): boolean { + return this._container === element || + this._input === element || + (typeof this._container?.contains === 'function' && this._container.contains(element)) || + (typeof this._inputParent?.contains === 'function' && this._inputParent.contains(element)); + } + + private _containsCurrentLayoutElement(element: HTMLElement): boolean { + const layoutService = this._layoutService as (ILayoutService & { + checkElementInCurrentContainers?: (element: HTMLElement) => boolean; + }) | undefined; + if (!layoutService) { + return this._container === element || ( + typeof this._container?.contains === 'function' && this._container.contains(element) + ); + } + if (layoutService.checkElementInCurrentContainers?.(element)) { + return true; + } + + const root = layoutService.rootContainerElement; + return root === element || + root?.contains(element) === true || + this._container === element || + (typeof this._container?.contains === 'function' && this._container.contains(element)); + } + private _detachEvent() { this._onInputBefore$.complete(); this._onKeydown$.complete(); diff --git a/packages/docs-ui/src/views/ParagraphMenu.tsx b/packages/docs-ui/src/views/ParagraphMenu.tsx index 704e9e657736..fb144d93a280 100644 --- a/packages/docs-ui/src/views/ParagraphMenu.tsx +++ b/packages/docs-ui/src/views/ParagraphMenu.tsx @@ -97,8 +97,6 @@ import { IDocClipboardService } from '../services/clipboard/clipboard.service'; import { DocEventManagerService } from '../services/doc-event-manager.service'; import { DocParagraphMenuService } from '../services/doc-paragraph-menu.service'; -export { DOC_PARAGRAPH_MENU_COMPONENT_KEY, DOC_TABLE_BLOCK_MENU_COMPONENT_KEY } from './paragraph-menu/component-keys'; - export function getParagraphMenuPopupDirection(anchorLeft: number, menuWidth = 212, viewportPadding = 8): 'left' | 'right' { return anchorLeft - menuWidth < viewportPadding ? 'right' : 'left'; } diff --git a/packages/docs-ui/src/views/rich-text-editor/hooks/use-left-and-right-arrow.ts b/packages/docs-ui/src/views/rich-text-editor/hooks/use-left-and-right-arrow.ts index 0204801f4384..a21ee4d5143d 100644 --- a/packages/docs-ui/src/views/rich-text-editor/hooks/use-left-and-right-arrow.ts +++ b/packages/docs-ui/src/views/rich-text-editor/hooks/use-left-and-right-arrow.ts @@ -18,13 +18,14 @@ import type { Editor } from '../../../services/editor/editor'; import { CommandType, Direction, DisposableCollection, ICommandService } from '@univerjs/core'; import { DeviceInputEventType } from '@univerjs/engine-render'; import { IShortcutService, KeyCode, MetaKeys, useDependency } from '@univerjs/ui'; -import { useEffect, useRef } from 'react'; +import { useEffect, useId, useRef } from 'react'; import { MoveCursorOperation, MoveSelectionOperation } from '../../../commands/operations/doc-cursor.operation'; // eslint-disable-next-line max-lines-per-function export const useLeftAndRightArrow = (isNeed: boolean, selectingMode: boolean, editor?: Editor, onMoveInEditor?: (keyCode: KeyCode, metaKey?: MetaKeys) => void) => { const commandService = useDependency(ICommandService); const shortcutService = useDependency(IShortcutService); + const operationId = `doc.rich-text-editor.arrow.${useId()}`; const selectingModeRef = useRef(selectingMode); selectingModeRef.current = selectingMode; const onMoveInEditorRef = useRef(onMoveInEditor); @@ -34,8 +35,6 @@ export const useLeftAndRightArrow = (isNeed: boolean, selectingMode: boolean, ed if (!editor || !isNeed) { return; } - const editorId = editor.getEditorId(); - const operationId = `doc.rich-text-editor.${editorId}`; const d = new DisposableCollection(); const handleMoveInEditor = (keycode: KeyCode, metaKey?: MetaKeys) => { if (onMoveInEditorRef.current) { @@ -87,7 +86,7 @@ export const useLeftAndRightArrow = (isNeed: boolean, selectingMode: boolean, ed return { id: operationId, binding: metaKey ? keyCode | metaKey : keyCode, - preconditions: () => true, + preconditions: () => editor.isFocus(), priority: 900, staticParameters: { eventType: DeviceInputEventType.Keyboard, @@ -102,5 +101,5 @@ export const useLeftAndRightArrow = (isNeed: boolean, selectingMode: boolean, ed return () => { d.dispose(); }; - }, [commandService, editor, isNeed, shortcutService]); + }, [commandService, editor, isNeed, operationId, shortcutService]); }; diff --git a/packages/docs/src/embed-host-anchor.ts b/packages/docs/src/embed-host-anchor.ts new file mode 100644 index 000000000000..4f4953dde1f0 --- /dev/null +++ b/packages/docs/src/embed-host-anchor.ts @@ -0,0 +1,258 @@ +/** + * 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 type { IDocDrawingBase, IMutationInfo, JSONXActions, Serializable } from '@univerjs/core'; +import type { IRichTextEditingMutationParams } from './commands/mutations/core-editing.mutation'; +import { AlignTypeH, DrawingTypeEnum, JSONX, ObjectRelativeFromH, ObjectRelativeFromV, PositionedObjectLayoutType, TextX, TextXActionType, UniverInstanceType } from '@univerjs/core'; +import { RichTextEditingMutation } from './commands/mutations/core-editing.mutation'; + +export interface IDocsCustomBlockMutationParams { + unitId: string; + blockId: string; + startIndex: number; + segmentId?: string; + drawingOrderIndex?: number; + embedId?: string; + childUnitId?: string; + childType?: UniverInstanceType; + componentKey?: string; + interactionMode?: EmbedDocsCustomBlockInteractionMode; +} + +export const EMBED_DOCS_CUSTOM_BLOCK_DEFAULT_COMPONENT_KEY = 'UniverEmbedDocsCustomBlock'; +export type EmbedDocsCustomBlockInteractionMode = 'block' | 'inline'; + +export interface IEmbedDocsCustomBlockData { + version: 1; + embedId: string; + hostUnitId?: string; + hostAnchorId: string; + childUnitId?: string; + childType?: UniverInstanceType; + interactionMode?: EmbedDocsCustomBlockInteractionMode; +} + +const DEFAULT_CUSTOM_BLOCK_SIZE = { width: 720, height: 360 }; +const SHEET_LIKE_CUSTOM_BLOCK_SIZE = { width: 960, height: 480 }; +const SLIDE_CUSTOM_BLOCK_SIZE = { width: 720, height: 405 }; + +export function createDocsCustomBlockInsertMutation(params: IDocsCustomBlockMutationParams): IMutationInfo { + return createRichTextMutation(params.unitId, params.segmentId, createInsertCustomBlockActions(params)); +} + +export function createDocsCustomBlockRemoveMutation(params: IDocsCustomBlockMutationParams): IMutationInfo { + return createRichTextMutation(params.unitId, params.segmentId, createRemoveCustomBlockActions(params)); +} + +export function createInsertCustomBlockActions(params: IDocsCustomBlockMutationParams): JSONXActions { + const textX = new TextX(); + if (params.startIndex > 0) { + textX.push({ + t: TextXActionType.RETAIN, + len: params.startIndex, + }); + } + + textX.push({ + t: TextXActionType.INSERT, + body: { + dataStream: '\b', + customBlocks: [{ + startIndex: 0, + blockId: params.blockId, + }], + }, + len: 1, + }); + + return composeActions([ + toBodyEditActions(textX, params.segmentId), + createDrawingInsertActions(params), + ]); +} + +export function createRemoveCustomBlockActions(params: IDocsCustomBlockMutationParams): JSONXActions { + const textX = new TextX(); + if (params.startIndex > 0) { + textX.push({ + t: TextXActionType.RETAIN, + len: params.startIndex, + }); + } + + textX.push({ + t: TextXActionType.DELETE, + len: 1, + }); + + return composeActions([ + toBodyEditActions(textX, params.segmentId), + createDrawingRemoveActions(params), + ]); +} + +export function createDocsCustomBlockDrawing(params: IDocsCustomBlockMutationParams): IDocDrawingBase { + const size = resolveDocsCustomBlockSize(params.childType); + const isInline = params.interactionMode === 'inline'; + const drawing: IDocDrawingBase & { componentKey: string; data: Serializable } = { + unitId: params.unitId, + subUnitId: params.unitId, + drawingId: params.blockId, + drawingType: DrawingTypeEnum.DRAWING_DOM, + componentKey: params.componentKey ?? EMBED_DOCS_CUSTOM_BLOCK_DEFAULT_COMPONENT_KEY, + data: createEmbedDocsCustomBlockData(params) as unknown as Serializable, + title: params.blockId, + description: 'Univer embedded unit custom block', + layoutType: isInline ? PositionedObjectLayoutType.INLINE : PositionedObjectLayoutType.WRAP_TOP_AND_BOTTOM, + allowTransform: false, + docTransform: { + size: { + width: size.width, + height: size.height, + }, + positionH: { + relativeFrom: isInline ? ObjectRelativeFromH.PAGE : ObjectRelativeFromH.COLUMN, + ...(isInline ? { posOffset: 0 } : { align: AlignTypeH.LEFT }), + }, + positionV: { + relativeFrom: isInline ? ObjectRelativeFromV.PAGE : ObjectRelativeFromV.PARAGRAPH, + posOffset: 0, + }, + angle: 0, + }, + transform: { + left: 0, + top: 0, + width: size.width, + height: size.height, + }, + }; + + return drawing; +} + +export function resolveDocsCustomBlockSize(childType?: UniverInstanceType): { width: number; height: number } { + if (childType === UniverInstanceType.UNIVER_SHEET || childType === UniverInstanceType.UNIVER_BASE) { + return SHEET_LIKE_CUSTOM_BLOCK_SIZE; + } + + if (childType === UniverInstanceType.UNIVER_SLIDE) { + return SLIDE_CUSTOM_BLOCK_SIZE; + } + + return DEFAULT_CUSTOM_BLOCK_SIZE; +} + +export function isSheetLikeDocsCustomBlockChildType(childType?: UniverInstanceType): boolean { + return childType === UniverInstanceType.UNIVER_SHEET || childType === UniverInstanceType.UNIVER_BASE; +} + +export function createEmbedDocsCustomBlockData(params: { + blockId: string; + embedId?: string; + unitId?: string; + childUnitId?: string; + childType?: UniverInstanceType; + interactionMode?: EmbedDocsCustomBlockInteractionMode; +}): IEmbedDocsCustomBlockData { + return { + version: 1, + embedId: params.embedId ?? params.blockId, + hostUnitId: params.unitId, + hostAnchorId: params.blockId, + childUnitId: params.childUnitId, + childType: params.childType, + interactionMode: params.interactionMode ?? 'block', + }; +} + +export function isEmbedDocsCustomBlockData(data: unknown): data is IEmbedDocsCustomBlockData { + if (!data || typeof data !== 'object') { + return false; + } + + const candidate = data as Partial; + return candidate.version === 1 && + typeof candidate.embedId === 'string' && + typeof candidate.hostAnchorId === 'string'; +} + +export function shouldUseInlineTextSelectionForDocsCustomBlockDrawing(drawing: unknown): boolean { + const data = drawing && typeof drawing === 'object' ? (drawing as { data?: unknown }).data : undefined; + if (!isEmbedDocsCustomBlockData(data)) { + return true; + } + + return data.interactionMode === 'inline'; +} + +function createRichTextMutation(unitId: string, segmentId: string | undefined, actions: JSONXActions): IMutationInfo { + return { + id: RichTextEditingMutation.id, + params: { + unitId, + segmentId, + actions, + textRanges: [], + isEditing: false, + noNeedSetTextRange: true, + }, + }; +} + +function toBodyEditActions(textX: TextX, segmentId?: string): JSONXActions { + const action = JSONX.getInstance().editOp(textX.serialize(), segmentId ? ['headers', segmentId, 'body'] : ['body']); + return action ?? []; +} + +function createDrawingInsertActions(params: IDocsCustomBlockMutationParams): JSONXActions { + if (params.segmentId) { + return []; + } + + const jsonX = JSONX.getInstance(); + const drawing = createDocsCustomBlockDrawing(params); + return composeActions([ + jsonX.insertOp(['drawings', params.blockId], drawing) ?? [], + jsonX.insertOp(['drawingsOrder', params.drawingOrderIndex ?? 0], params.blockId) ?? [], + ]); +} + +function createDrawingRemoveActions(params: IDocsCustomBlockMutationParams): JSONXActions { + if (params.segmentId) { + return []; + } + + const jsonX = JSONX.getInstance(); + const drawing = createDocsCustomBlockDrawing(params); + return composeActions([ + jsonX.removeOp(['drawings', params.blockId], drawing) ?? [], + jsonX.removeOp(['drawingsOrder', params.drawingOrderIndex ?? 0], params.blockId) ?? [], + ]); +} + +function composeActions(actions: Array): JSONXActions { + return actions.reduce((composed, action) => { + if (!action || JSONX.isNoop(action) || action.length === 0) { + return composed; + } + if (!composed || JSONX.isNoop(composed) || composed.length === 0) { + return action; + } + + return JSONX.compose(composed, action) ?? []; + }, [] as JSONXActions); +} diff --git a/packages/docs/src/facade/f-types.ts b/packages/docs/src/facade/f-types.ts new file mode 100644 index 000000000000..7eb5c6757291 --- /dev/null +++ b/packages/docs/src/facade/f-types.ts @@ -0,0 +1,17 @@ +/** + * 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. + */ + +export type FDocEmbedUnitFacadeMapAugmentation = never; diff --git a/packages/docs/src/facade/index.ts b/packages/docs/src/facade/index.ts index 53d540a081bb..1f252fcc1a68 100644 --- a/packages/docs/src/facade/index.ts +++ b/packages/docs/src/facade/index.ts @@ -19,5 +19,6 @@ import './f-univer'; export { FDocument } from './f-document'; export { FDocumentParagraph, isParagraphFacade } from './f-document-paragraph'; export type { IFDocumentParagraphInfo } from './f-document-paragraph'; +export type { FDocEmbedUnitFacadeMapAugmentation } from './f-types'; export type { IFDocumentTextRange } from './utils'; export { stripBlockTokens } from './utils'; diff --git a/packages/docs/src/index.ts b/packages/docs/src/index.ts index 2ada3733f418..08b89bbded2b 100644 --- a/packages/docs/src/index.ts +++ b/packages/docs/src/index.ts @@ -21,15 +21,43 @@ export type { IUpdateTextCommandParams, } from './commands/commands/core-editing.command'; export { CreateHeaderFooterCommand, HeaderFooterType } from './commands/commands/create-header-footer.command'; -export type { HeaderFooterCreateMode, ICreateHeaderFooterCommandParams, IHeaderFooterProps } from './commands/commands/create-header-footer.command'; +export type { + HeaderFooterCreateMode, + ICreateHeaderFooterCommandParams, + IHeaderFooterProps, +} from './commands/commands/create-header-footer.command'; export { RichTextEditingMutation } from './commands/mutations/core-editing.mutation'; export type { IRichTextEditingMutationParams } from './commands/mutations/core-editing.mutation'; export { SetTextSelectionsOperation } from './commands/operations/text-selection.operation'; export type { ISetTextSelectionsOperationParams } from './commands/operations/text-selection.operation'; export type { IUniverDocsConfig } from './config/config'; +export { + createDocsCustomBlockDrawing, + createDocsCustomBlockInsertMutation, + createDocsCustomBlockRemoveMutation, + createEmbedDocsCustomBlockData, + createInsertCustomBlockActions, + createRemoveCustomBlockActions, + EMBED_DOCS_CUSTOM_BLOCK_DEFAULT_COMPONENT_KEY, + isEmbedDocsCustomBlockData, + isSheetLikeDocsCustomBlockChildType, + resolveDocsCustomBlockSize, + shouldUseInlineTextSelectionForDocsCustomBlockDrawing, +} from './embed-host-anchor'; +export type { + EmbedDocsCustomBlockInteractionMode, + IDocsCustomBlockMutationParams, + IEmbedDocsCustomBlockData, +} from './embed-host-anchor'; export { UniverDocsPlugin } from './plugin'; export { DocBlockMoveValidatorService } from './services/doc-block-move-validator.service'; -export type { DocBlockMoveTransformer, DocBlockMoveValidator, IDocBlockMoveResult, IDocBlockMoveTransformContext, IDocBlockMoveValidationContext } from './services/doc-block-move-validator.service'; +export type { + DocBlockMoveTransformer, + DocBlockMoveValidator, + IDocBlockMoveResult, + IDocBlockMoveTransformContext, + IDocBlockMoveValidationContext, +} from './services/doc-block-move-validator.service'; export { DocContentInsertService } from './services/doc-content-insert.service'; export type { IDocContentInsertRange } from './services/doc-content-insert.service'; export { DocInterceptorService } from './services/doc-interceptor/doc-interceptor.service'; diff --git a/packages/drawing-ui/src/controllers/__tests__/drawing-update.controller.spec.ts b/packages/drawing-ui/src/controllers/__tests__/drawing-update.controller.spec.ts index 691a387c6930..f6dafe9dc988 100644 --- a/packages/drawing-ui/src/controllers/__tests__/drawing-update.controller.spec.ts +++ b/packages/drawing-ui/src/controllers/__tests__/drawing-update.controller.spec.ts @@ -276,6 +276,7 @@ describe('DrawingUpdateController', () => { harness.refreshTransform$.next([{ unitId: 'unit-1', subUnitId: 'sheet-1', drawingId: 'drawing-refresh-missing' }]); + expect(harness.drawingManagerService.addNotification).toHaveBeenCalledTimes(1); expect(harness.drawingManagerService.addNotification).toHaveBeenCalledWith([ { unitId: 'unit-1', subUnitId: 'sheet-1', drawingId: 'drawing-refresh-missing' }, ]); diff --git a/packages/engine-formula/src/engine/analysis/lexer.ts b/packages/engine-formula/src/engine/analysis/lexer.ts index fda7a3d3366e..16d173d4aa4a 100644 --- a/packages/engine-formula/src/engine/analysis/lexer.ts +++ b/packages/engine-formula/src/engine/analysis/lexer.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import type { Nullable } from '@univerjs/core'; import { Disposable, Inject } from '@univerjs/core'; import { IFormulaCurrentConfigService } from '../../services/current-data.service'; import { IDefinedNamesService } from '../../services/defined-names.service'; @@ -28,13 +29,13 @@ export class Lexer extends Disposable { super(); } - treeBuilder(formulaString: string, transformSuffix = true) { + treeBuilder(formulaString: string, transformSuffix = true, unitId?: Nullable) { if (this._definedNamesService.getAllDefinedNamesIsEmpty()) { return this._lexerTreeBuilder.treeBuilder(formulaString, transformSuffix); } return this._lexerTreeBuilder.treeBuilder(formulaString, transformSuffix, { - unitId: this._formulaCurrentConfigService.getExecuteUnitId(), + unitId: unitId ?? this._formulaCurrentConfigService.getExecuteUnitId(), sheetId: this._formulaCurrentConfigService.getExecuteSubUnitId(), getValueByName: this._definedNamesService.getValueByName.bind(this._definedNamesService), getDirtyDefinedNameMap: this._formulaCurrentConfigService.getDirtyDefinedNameMap.bind(this._formulaCurrentConfigService), diff --git a/packages/engine-formula/src/engine/ast-node/function-node.ts b/packages/engine-formula/src/engine/ast-node/function-node.ts index 6b1c593e6c96..b7ea7bfe71a2 100644 --- a/packages/engine-formula/src/engine/ast-node/function-node.ts +++ b/packages/engine-formula/src/engine/ast-node/function-node.ts @@ -367,7 +367,7 @@ export class FunctionNode extends BaseAstNode { } private _setDefinedNamesForFunction() { - const editorUnitId = this._currentConfigService.getExecuteUnitId(); + const editorUnitId = this._runtimeService.currentUnitId; if (editorUnitId == null) { return; } diff --git a/packages/engine-formula/src/engine/ast-node/reference-node.ts b/packages/engine-formula/src/engine/ast-node/reference-node.ts index 07a1d5fdcf1c..4272a99a7891 100644 --- a/packages/engine-formula/src/engine/ast-node/reference-node.ts +++ b/packages/engine-formula/src/engine/ast-node/reference-node.ts @@ -172,7 +172,7 @@ export class ReferenceNodeFactory extends BaseAstNodeFactory { } private _getTableMap() { - const unitId = this._currentConfigService.getExecuteUnitId(); + const unitId = this._formulaRuntimeService.currentUnitId; if (!unitId) { return; } diff --git a/packages/engine-formula/src/engine/ast-node/suffix-node.ts b/packages/engine-formula/src/engine/ast-node/suffix-node.ts index 1ee749cdfeaa..2b55f19ed758 100644 --- a/packages/engine-formula/src/engine/ast-node/suffix-node.ts +++ b/packages/engine-formula/src/engine/ast-node/suffix-node.ts @@ -108,7 +108,7 @@ export class SuffixNode extends BaseAstNode { return ErrorValueObject.create(ErrorType.VALUE); } - const lexerNode = this._lexer.treeBuilder(formulaString); + const lexerNode = this._lexer.treeBuilder(formulaString, true, unitId); return ErrorValueObject.create(ErrorType.VALUE); /** todo */ diff --git a/packages/engine-formula/src/engine/dependency/formula-dependency.ts b/packages/engine-formula/src/engine/dependency/formula-dependency.ts index 817bf61146d0..76e03a631d8b 100644 --- a/packages/engine-formula/src/engine/dependency/formula-dependency.ts +++ b/packages/engine-formula/src/engine/dependency/formula-dependency.ts @@ -720,6 +720,15 @@ export class FormulaDependencyGenerator extends Disposable implements IFormulaDe continue; } + this._runtimeService.setCurrent( + tree.row, + tree.column, + tree.rowCount, + tree.columnCount, + tree.subUnitId, + tree.unitId + ); + const node = this._getTreeNode(tree); tree.isDirty = this._includeTree(tree, node); @@ -732,15 +741,6 @@ export class FormulaDependencyGenerator extends Disposable implements IFormulaDe continue; } - this._runtimeService.setCurrent( - tree.row, - tree.column, - tree.rowCount, - tree.columnCount, - tree.subUnitId, - tree.unitId - ); - const rangeList = await this._getRangeListByNode({ node, refOffsetX: tree.refOffsetX, @@ -1095,14 +1095,6 @@ export class FormulaDependencyGenerator extends Disposable implements IFormulaDe private async _calculateAddressFunctionRuntimeData(treeDependencyCache: RTree, preCalculateTreeList: IFormulaDependencyTree[]) { while (preCalculateTreeList.length > 0) { const tree = preCalculateTreeList.pop()!; - const node = this._getTreeNode(tree); - const nodeData = { - node, - refOffsetX: tree.refOffsetX, - refOffsetY: tree.refOffsetY, - }; - - await this._calculateAddressFunction(treeDependencyCache, tree); this._runtimeService.setCurrent( tree.row, @@ -1113,6 +1105,15 @@ export class FormulaDependencyGenerator extends Disposable implements IFormulaDe tree.unitId ); + const node = this._getTreeNode(tree); + const nodeData = { + node, + refOffsetX: tree.refOffsetX, + refOffsetY: tree.refOffsetY, + }; + + await this._calculateAddressFunction(treeDependencyCache, tree); + let value: FunctionVariantType; if (this._interpreter.checkAsyncNode(nodeData.node)) { value = await this._interpreter.executeAsync(nodeData); diff --git a/packages/engine-formula/src/engine/utils/generate-ast-node.ts b/packages/engine-formula/src/engine/utils/generate-ast-node.ts index bf658192518f..dd1dd23f03d3 100644 --- a/packages/engine-formula/src/engine/utils/generate-ast-node.ts +++ b/packages/engine-formula/src/engine/utils/generate-ast-node.ts @@ -57,12 +57,12 @@ export function generateAstNode( const noCache = checkIsChangedByDefinedName(unitId, formulaString, currentConfigService) || checkIsChangedBySuperTable(unitId, formulaString, currentConfigService); - if (!noCache && astNode && !isDirtyDefinedForNode(astNode, currentConfigService)) { + if (!noCache && astNode && !isDirtyDefinedForNode(astNode, currentConfigService, unitId)) { // astNode.setRefOffset(refOffsetX, refOffsetY); return astNode; } - const lexerNode = lexer.treeBuilder(formulaString); + const lexerNode = lexer.treeBuilder(formulaString, true, unitId); if (ERROR_TYPE_SET.has(lexerNode as ErrorType)) { return ErrorNode.create(lexerNode as ErrorType); @@ -141,9 +141,9 @@ function getDirtySuperTableReferencePattern(unitSuperTableMap: IDirtyStringMap): return tableReferencePattern; } -function isDirtyDefinedForNode(node: BaseAstNode, currentConfigService: IFormulaCurrentConfigService) { +function isDirtyDefinedForNode(node: BaseAstNode, currentConfigService: IFormulaCurrentConfigService, unitId: string) { const definedNameMap = currentConfigService.getDirtyDefinedNameMap(); - const executeUnitId = currentConfigService.getExecuteUnitId(); + const executeUnitId = unitId; if (executeUnitId != null && definedNameMap[executeUnitId] != null) { const names = Object.keys(definedNameMap[executeUnitId]!); for (let i = 0, len = names.length; i < len; i++) { @@ -163,7 +163,7 @@ export function includeDefinedName(tree: IFormulaDependencyTree, node: Nullable< */ // const node = tree.nodeData?.node; if (node != null) { - const dirtyDefinedName = isDirtyDefinedForNode(node, currentConfigService); + const dirtyDefinedName = isDirtyDefinedForNode(node, currentConfigService, tree.unitId); if (dirtyDefinedName) { return true; } diff --git a/packages/engine-formula/src/models/__tests__/formula-data.model.spec.ts b/packages/engine-formula/src/models/__tests__/formula-data.model.spec.ts index 9eb6cbc324e4..11f78b1364b5 100644 --- a/packages/engine-formula/src/models/__tests__/formula-data.model.spec.ts +++ b/packages/engine-formula/src/models/__tests__/formula-data.model.spec.ts @@ -879,6 +879,39 @@ describe('Test formula data model', () => { ]); }); + it('should ignore non-sheet formula units when calculating sheet dirty ranges', () => { + const univerInstanceService = get(IUniverInstanceService); + univerInstanceService.registerCtorForType(UniverInstanceType.UNIVER_BASE, BaseDataModel); + univer.createUnit(UniverInstanceType.UNIVER_BASE, TEST_BASE_DATA); + + const dirtyRanges = formulaDataModel.getFormulaDirtyRanges(); + + expect(dirtyRanges).toEqual([ + { + unitId: 'test', + sheetId: 'sheet1', + range: { + rangeType: RANGE_TYPE.NORMAL, + startRow: 0, + endRow: 1, + startColumn: 0, + endColumn: 0, + }, + }, + { + unitId: 'test', + sheetId: 'sheet1', + range: { + rangeType: RANGE_TYPE.NORMAL, + startRow: 3, + endRow: 3, + startColumn: 0, + endColumn: 0, + }, + }, + ]); + }); + it('should expose calculate data and per-sheet formula data', () => { const calculateData = formulaDataModel.getCalculateData(); expect(calculateData.allUnitData.test?.sheet1.rowCount).toBeGreaterThan(0); diff --git a/packages/engine-formula/src/models/formula-data.model.ts b/packages/engine-formula/src/models/formula-data.model.ts index 0a1ed073dfe1..93aeab0fe475 100644 --- a/packages/engine-formula/src/models/formula-data.model.ts +++ b/packages/engine-formula/src/models/formula-data.model.ts @@ -664,7 +664,7 @@ export class FormulaDataModel extends Disposable { if (!workbook) continue; - const workbookInstance = this._univerInstanceService.getUnit(unitId); + const workbookInstance = this._univerInstanceService.getUnit(unitId, UniverInstanceType.UNIVER_SHEET); if (!workbookInstance) continue; diff --git a/packages/engine-formula/src/services/__tests__/calculate-formula.service.spec.ts b/packages/engine-formula/src/services/__tests__/calculate-formula.service.spec.ts index 487983418792..425b55208c67 100644 --- a/packages/engine-formula/src/services/__tests__/calculate-formula.service.spec.ts +++ b/packages/engine-formula/src/services/__tests__/calculate-formula.service.spec.ts @@ -51,6 +51,7 @@ function createService() { setExecuteSubUnitId: vi.fn(), getDirtyData: vi.fn(() => ({})), getDirtyDefinedNameMap: vi.fn(() => ({})), + getExecuteUnitId: vi.fn(() => 'unit'), }; const runtimeService = { setFormulaExecuteStage: vi.fn(), @@ -121,6 +122,7 @@ function createService() { setExecuteSubUnitId = currentConfigService.setExecuteSubUnitId; getDirtyData = currentConfigService.getDirtyData; getDirtyDefinedNameMap = currentConfigService.getDirtyDefinedNameMap; + getExecuteUnitId = currentConfigService.getExecuteUnitId; } class TestRuntimeService { diff --git a/packages/engine-formula/src/services/calculate-formula.service.ts b/packages/engine-formula/src/services/calculate-formula.service.ts index 27a26832574d..c8638778a9ec 100644 --- a/packages/engine-formula/src/services/calculate-formula.service.ts +++ b/packages/engine-formula/src/services/calculate-formula.service.ts @@ -75,7 +75,7 @@ export interface ICalculateFormulaService { setRuntimeFeatureRange(featureId: string, featureRange: IFeatureDirtyRangeType): void; execute(formulaDatasetConfig: IFormulaDatasetConfig): Promise; stopFormulaExecution(): void; - calculate(formulaString: string, transformSuffix?: boolean): void; + calculate(formulaString: string, transformSuffix?: boolean, unitId?: string): void; executeFormulas(formulas: IFormulaStringMap, rowData?: IUnitRowData): Promise; getAllDependencyJson(rowData?: IUnitRowData): Promise; getCellDependencyJson(unitId: string, sheetId: string, row: number, column: number, rowData?: IUnitRowData): Promise; @@ -298,6 +298,16 @@ export class CalculateFormulaService extends Disposable implements ICalculateFor const treeCount = treeList.length; while (treeList.length > 0) { const tree = treeList.pop()!; + + this._runtimeService.setCurrent( + tree.row, + tree.column, + tree.rowCount, + tree.columnCount, + tree.subUnitId, + tree.unitId + ); + const node = generateAstNode(tree.unitId, tree.formula, this._lexer, this._astTreeBuilder, this._currentConfigService, tree.subUnitId); const nodeData = { node, @@ -338,15 +348,6 @@ export class CalculateFormulaService extends Disposable implements ICalculateFor } } - this._runtimeService.setCurrent( - tree.row, - tree.column, - tree.rowCount, - tree.columnCount, - tree.subUnitId, - tree.unitId - ); - let value: FunctionVariantType; if (getDirtyData != null && tree.featureId != null) { @@ -497,10 +498,11 @@ export class CalculateFormulaService extends Disposable implements ICalculateFor return result; } - async calculate(formulaString: string) { + async calculate(formulaString: string, transformSuffix = true, unitId?: string) { // TODO how to observe @alex // this.getObserver('onBeforeFormulaCalculateObservable')?.notifyObservers(formulaString); - const lexerNode = this._lexer.treeBuilder(formulaString); + const executeUnitId = unitId || this._runtimeService.currentUnitId || this._currentConfigService.getExecuteUnitId(); + const lexerNode = this._lexer.treeBuilder(formulaString, transformSuffix, executeUnitId); if (Object.values(ErrorType).includes(lexerNode as ErrorType)) { return; diff --git a/packages/engine-render/src/__tests__/engine-scene-viewport.spec.ts b/packages/engine-render/src/__tests__/engine-scene-viewport.spec.ts index 10dd5b75c10b..3f559330041f 100644 --- a/packages/engine-render/src/__tests__/engine-scene-viewport.spec.ts +++ b/packages/engine-render/src/__tests__/engine-scene-viewport.spec.ts @@ -626,6 +626,38 @@ describe('engine scene viewport extra', () => { engine.dispose(); }); + it('expands transformer outline symmetrically when border spacing is configured', () => { + const { engine, scene } = createFixture(); + const rect = scene.getObject('rect-main') as Rect; + rect.transformerConfig = { + borderSpacing: 6, + borderStrokeWidth: 1, + anchorStyle: 'canva', + anchorSize: 8, + }; + const transformer = new Transformer(scene); + + expect((transformer as any)._getOutlinePosition(100, 40, 6, 1)).toEqual({ + left: -7, + top: -7, + width: 112, + height: 52, + }); + + transformer.setSelectedControl(rect); + const control = (transformer as any)._transformerControlMap.get(rect.oKey) as Group; + const controlObjects = control.getObjects(); + const outline = controlObjects.find((o) => o.oKey.includes('__SpreadsheetTransformerOutline__')) as Rect; + const leftMiddle = controlObjects.find((o) => o.oKey.includes('__SpreadsheetTransformerResizeLM__')) as Rect; + + expect(outline.left).toBe(-7); + expect(leftMiddle.left + leftMiddle.width / 2).toBe(outline.left); + + transformer.dispose(); + scene.dispose(); + engine.dispose(); + }); + it('rotates around the scene center when the scene is scaled', () => { const sceneMock = { ancestorScaleX: 2, diff --git a/packages/engine-render/src/basics/i-document-skeleton-cached.ts b/packages/engine-render/src/basics/i-document-skeleton-cached.ts index 3703110d7ec7..d25ac83f4a56 100644 --- a/packages/engine-render/src/basics/i-document-skeleton-cached.ts +++ b/packages/engine-render/src/basics/i-document-skeleton-cached.ts @@ -296,6 +296,14 @@ export interface IDocumentSkeletonDrawing { lineTop: number; lineHeight: number; blockAnchorTop: number; // The paragraph top. + customBlockRenderViewport?: { + bleedLeft?: number; + bleedWidth?: number; + contentHeight?: number; + contentWidth?: number; + height?: number; + viewportHeight?: number; + }; } export interface IDocumentSkeletonFontStyle { diff --git a/packages/engine-render/src/basics/interfaces.ts b/packages/engine-render/src/basics/interfaces.ts index 4e3974635359..260b75d0f3d5 100644 --- a/packages/engine-render/src/basics/interfaces.ts +++ b/packages/engine-render/src/basics/interfaces.ts @@ -133,6 +133,7 @@ export interface IParagraphConfig { docxFallbackAnchorLeft?: IParagraphStyle['indentStart']; paragraphNonInlineSkeDrawings?: Map; paragraphInlineSkeDrawings?: Map; + topBottomCustomBlockFlowBottom?: number; skeTablesInParagraph?: IParagraphTableCache[]; // headerAndFooterAffectSkeDrawings?: Map; bulletSkeleton?: IDocumentSkeletonBullet; diff --git a/packages/engine-render/src/components/docs/custom-block-render-viewport.ts b/packages/engine-render/src/components/docs/custom-block-render-viewport.ts new file mode 100644 index 000000000000..ed67c2cb9d4f --- /dev/null +++ b/packages/engine-render/src/components/docs/custom-block-render-viewport.ts @@ -0,0 +1,82 @@ +/** + * 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. + */ + +export interface IDocsCustomBlockRenderViewportInput { + blockLeft?: number; + fallbackHeight: number; + fallbackWidth: number; + pageMarginLeft?: number; + pageMarginRight?: number; + pageWidth?: number; +} + +export interface IDocsCustomBlockRenderViewport { + bleedLeft?: number; + bleedWidth?: number; + contentHeight?: number; + contentWidth?: number; + height: number; + layoutWidth?: number; + offsetLeft?: number; + viewportHeight?: number; + width: number; +} + +export type DocsCustomBlockRenderViewportProvider = ( + unitId: string, + blockId: string, + input: IDocsCustomBlockRenderViewportInput +) => IDocsCustomBlockRenderViewport | null | undefined; + +let docsCustomBlockRenderViewportProvider: DocsCustomBlockRenderViewportProvider | null = null; +const docsCustomBlockRenderViewportProviders = new Set(); + +export function setDocsCustomBlockRenderViewportProvider(provider: DocsCustomBlockRenderViewportProvider | null): () => void { + if (provider == null) { + docsCustomBlockRenderViewportProvider = null; + docsCustomBlockRenderViewportProviders.clear(); + return () => {}; + } + + docsCustomBlockRenderViewportProvider = provider; + docsCustomBlockRenderViewportProviders.add(provider); + return () => { + docsCustomBlockRenderViewportProviders.delete(provider); + const providers = Array.from(docsCustomBlockRenderViewportProviders); + docsCustomBlockRenderViewportProvider = providers[providers.length - 1] ?? null; + }; +} + +export function getDocsCustomBlockRenderViewport( + unitId: string, + blockId: string, + input: IDocsCustomBlockRenderViewportInput +): IDocsCustomBlockRenderViewport | null { + const providers = docsCustomBlockRenderViewportProviders.size + ? Array.from(docsCustomBlockRenderViewportProviders).reverse() + : docsCustomBlockRenderViewportProvider + ? [docsCustomBlockRenderViewportProvider] + : []; + + for (const provider of providers) { + const viewport = provider(unitId, blockId, input); + if (viewport != null) { + return viewport; + } + } + + return null; +} diff --git a/packages/engine-render/src/components/docs/layout/block/paragraph/__tests__/layout-ruler.spec.ts b/packages/engine-render/src/components/docs/layout/block/paragraph/__tests__/layout-ruler.spec.ts index 58971ac80604..f35ab81c733e 100644 --- a/packages/engine-render/src/components/docs/layout/block/paragraph/__tests__/layout-ruler.spec.ts +++ b/packages/engine-render/src/components/docs/layout/block/paragraph/__tests__/layout-ruler.spec.ts @@ -20,14 +20,17 @@ import { BooleanNumber, DataStreamTreeTokenType, DocumentFlavor, + DrawingTypeEnum, GridType, ObjectRelativeFromV, PositionedObjectLayoutType, SpacingRule, WrapTextType, } from '@univerjs/core'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { GlyphType, LineType } from '../../../../../../basics/i-document-skeleton-cached'; +import { setDocsCustomBlockRenderViewportProvider } from '../../../../custom-block-render-viewport'; +import { createSkeletonCustomBlockGlyph } from '../../../model/glyph'; import { __testing, getLineHeightMetrics, layoutParagraph, updateInlineDrawingPosition } from '../layout-ruler'; import { lineBreaking } from '../linebreaking'; import { shaping } from '../shaping'; @@ -90,6 +93,10 @@ describe('layout-ruler', () => { }; } + afterEach(() => { + setDocsCustomBlockRenderViewportProvider(null); + }); + function getLineBoxHeight(metrics: ReturnType) { return metrics.paddingTop + metrics.contentHeight + metrics.paddingBottom; } @@ -621,7 +628,7 @@ describe('layout-ruler', () => { section.columns = [column]; column.lines = [line]; - updateInlineDrawingPosition(line, new Map([['image-1', drawing]]), 80); + updateInlineDrawingPosition(line, new Map([['image-1', drawing]]), '', 80); expect(page.skeDrawings.get('old-image')).toEqual({ drawingId: 'old-image' }); expect(page.skeDrawings.get('image-1')).toMatchObject({ @@ -665,4 +672,95 @@ describe('layout-ruler', () => { expect(table.left).toBe(108); }); + + it('stores custom block render viewport on inline skeleton drawings', () => { + setDocsCustomBlockRenderViewportProvider(() => ({ + bleedLeft: 12, + bleedWidth: 360, + contentHeight: 120, + contentWidth: 320, + height: 80, + viewportHeight: 64, + layoutWidth: 180, + width: 180, + })); + + const page = { + marginLeft: 20, + marginRight: 20, + pageWidth: 400, + skeDrawings: new Map(), + }; + const section = { parent: page }; + const column = { left: 0, parent: section }; + const paragraphInlineSkeDrawings = new Map([ + [ + 'b1', + { + drawingId: 'b1', + aLeft: 0, + aTop: 0, + width: 0, + height: 0, + angle: 0, + initialState: false, + columnLeft: 0, + lineHeight: 0, + lineTop: 0, + blockAnchorTop: 0, + isPageBreak: false, + drawingOrigin: { + drawingId: 'b1', + drawingType: DrawingTypeEnum.DRAWING_DOM, + layoutType: PositionedObjectLayoutType.INLINE, + docTransform: { + angle: 0, + size: { height: 60, width: 120 }, + }, + transform: { + height: 60, + left: 0, + top: 0, + width: 120, + }, + }, + }, + ], + ]); + const glyph = createSkeletonCustomBlockGlyph({ + charSpace: 1, + fontStyle: { + fontCache: '', + fontFamily: 'Arial', + fontSize: 12, + fontString: '12px Arial', + originFontSize: 12, + }, + snapToGrid: BooleanNumber.FALSE, + textStyle: {}, + }, 180, 80, 'b1'); + + updateInlineDrawingPosition({ + divides: [{ + glyphGroup: [glyph], + left: 0, + paddingLeft: 0, + }], + lineHeight: 100, + marginBottom: 0, + parent: column, + top: 10, + } as never, paragraphInlineSkeDrawings as never, 'test-doc', 10); + + const drawing = page.skeDrawings.get('b1'); + expect(drawing?.width).toBe(180); + expect(drawing?.height).toBe(80); + expect(drawing?.customBlockRenderViewport?.bleedLeft).toBe(12); + expect(drawing?.customBlockRenderViewport?.bleedWidth).toBe(360); + expect(drawing?.customBlockRenderViewport?.contentHeight).toBe(120); + expect(drawing?.customBlockRenderViewport?.contentWidth).toBe(320); + expect(drawing?.customBlockRenderViewport?.height).toBe(80); + expect(drawing?.customBlockRenderViewport?.viewportHeight).toBe(64); + expect(drawing?.aTop).toBe(30); + }); }); diff --git a/packages/engine-render/src/components/docs/layout/block/paragraph/__tests__/linebreaking.spec.ts b/packages/engine-render/src/components/docs/layout/block/paragraph/__tests__/linebreaking.spec.ts index 50508934a216..4a58a35787ae 100644 --- a/packages/engine-render/src/components/docs/layout/block/paragraph/__tests__/linebreaking.spec.ts +++ b/packages/engine-render/src/components/docs/layout/block/paragraph/__tests__/linebreaking.spec.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import type { IDocumentSkeletonPage } from '../../../../../../basics/i-document-skeleton-cached'; import { AlignTypeH, AlignTypeV, @@ -25,7 +26,8 @@ import { PositionedObjectLayoutType, WrapTextType, } from '@univerjs/core'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { setDocsCustomBlockRenderViewportProvider } from '../../../../custom-block-render-viewport'; import { updateInlineDrawingCoordsAndBorder } from '../../../tools'; import { lineBreaking } from '../linebreaking'; import { shaping } from '../shaping'; @@ -61,6 +63,9 @@ describe('linebreaking', () => { }), }); }); + afterEach(() => { + setDocsCustomBlockRenderViewportProvider(null); + }); it('lays out short text on a single page', () => { const { viewModel, ctx, paragraphNode, sectionBreakConfig, curPage } = createParagraphLayoutTestBed('Hi'); @@ -123,6 +128,86 @@ describe('linebreaking', () => { expect(result.length).toBe(1); }); + it('keeps top-bottom custom blocks in the positioned drawing bucket', () => { + const { viewModel, ctx, paragraphNode, sectionBreakConfig, curPage } = createParagraphLayoutTestBed(DataStreamTreeTokenType.CUSTOM_BLOCK, { + body: { + customBlocks: [{ startIndex: 0, blockId: 'b1' }], + }, + documentStyle: { + documentFlavor: DocumentFlavor.MODERN, + }, + drawings: { + b1: { + drawingId: 'b1', + layoutType: PositionedObjectLayoutType.WRAP_TOP_AND_BOTTOM, + docTransform: { + angle: 0, + positionH: { relativeFrom: ObjectRelativeFromH.COLUMN, posOffset: 0 }, + positionV: { relativeFrom: ObjectRelativeFromV.PARAGRAPH, posOffset: 0 }, + size: { width: 100, height: 120 }, + }, + }, + }, + }); + const shapedTextList = shaping(ctx, paragraphNode.content!, viewModel, paragraphNode, sectionBreakConfig); + + lineBreaking(ctx, viewModel, shapedTextList, curPage, paragraphNode, sectionBreakConfig, null); + + const paragraphConfig = ctx.paragraphConfigCache.get(curPage.segmentId)?.get(paragraphNode.endIndex); + expect(paragraphConfig?.paragraphInlineSkeDrawings?.has('b1')).toBe(false); + expect(paragraphConfig?.paragraphNonInlineSkeDrawings?.has('b1')).toBe(true); + + const line = curPage.sections[0].columns[0].lines[0]; + expect(line.lineHeight).toBeLessThan(120); + + const drawing = curPage.skeDrawings.get('b1'); + expect(drawing?.height).toBe(120); + }); + + it('uses measured custom block viewport height to push following paragraphs', () => { + setDocsCustomBlockRenderViewportProvider(() => ({ + contentHeight: 240, + contentWidth: 160, + height: 240, + viewportHeight: 120, + width: 160, + })); + + const { viewModel, ctx, sectionNode, sectionBreakConfig, curPage } = createSectionLayoutTestBed([DataStreamTreeTokenType.CUSTOM_BLOCK, 'After block'], { + body: { + customBlocks: [{ startIndex: 0, blockId: 'b1' }], + }, + documentStyle: { + documentFlavor: DocumentFlavor.MODERN, + pageSize: { width: 400, height: 1200 }, + }, + drawings: { + b1: { + drawingId: 'b1', + layoutType: PositionedObjectLayoutType.WRAP_TOP_AND_BOTTOM, + docTransform: { + angle: 0, + positionH: { relativeFrom: ObjectRelativeFromH.COLUMN, posOffset: 0 }, + positionV: { relativeFrom: ObjectRelativeFromV.PARAGRAPH, posOffset: 0 }, + size: { width: 160, height: 80 }, + }, + }, + }, + }); + const [blockParagraph, textParagraph] = sectionNode.children; + const blockShapedTextList = shaping(ctx, blockParagraph.content!, viewModel, blockParagraph, sectionBreakConfig); + const afterBlockPages = lineBreaking(ctx, viewModel, blockShapedTextList, curPage, blockParagraph, sectionBreakConfig, null); + const textShapedTextList = shaping(ctx, textParagraph.content!, viewModel, textParagraph, sectionBreakConfig); + const result = lineBreaking(ctx, viewModel, textShapedTextList, afterBlockPages[afterBlockPages.length - 1], textParagraph, sectionBreakConfig, null); + + const page = result[0]; + const drawing = page.skeDrawings.get('b1'); + const textLine = page.sections[0].columns[0].lines.find((line) => line.paragraphIndex === textParagraph.endIndex); + + expect(drawing?.height).toBe(240); + expect(textLine?.top).toBeGreaterThanOrEqual((drawing?.aTop ?? 0) + (drawing?.height ?? 0)); + }); + it('ignores custom blocks that reference missing drawings', () => { const content = `A${DataStreamTreeTokenType.CUSTOM_BLOCK}B`; const { viewModel, ctx, paragraphNode, sectionBreakConfig, curPage } = createParagraphLayoutTestBed(content, { @@ -1481,4 +1566,368 @@ describe('linebreaking', () => { expect(ctx.paragraphConfigCache.get('segment-1')?.get(3)?.useWordStyleLineHeight).toBe(false); expect(paragraphStyle).toEqual({}); }); + + it('keeps multiple measured top-bottom custom blocks in document-flow order', () => { + const heights: Record = { + b1: 19004, + b2: 5156, + b3: 405, + }; + setDocsCustomBlockRenderViewportProvider((_unitId, blockId, input) => { + const height = heights[blockId]; + if (height == null) { + return null; + } + + return { + contentHeight: height, + contentWidth: input.fallbackWidth, + height, + viewportHeight: Math.min(height, 1123), + width: input.fallbackWidth, + }; + }); + + const contents = ['Embed host document', 'Inserted line above block', DataStreamTreeTokenType.CUSTOM_BLOCK, DataStreamTreeTokenType.CUSTOM_BLOCK, DataStreamTreeTokenType.CUSTOM_BLOCK]; + const { viewModel, ctx, sectionNode, sectionBreakConfig, curPage } = createSectionLayoutTestBed(contents, { + body: { + customBlocks: [ + { startIndex: 46, blockId: 'b1' }, + { startIndex: 48, blockId: 'b2' }, + { startIndex: 50, blockId: 'b3' }, + ], + }, + documentStyle: { + documentFlavor: DocumentFlavor.MODERN, + pageSize: { width: 1200, height: Number.POSITIVE_INFINITY }, + }, + drawings: { + b1: createTopBottomDrawing('b1', 960, 480), + b2: createTopBottomDrawing('b2', 960, 480), + b3: createTopBottomDrawing('b3', 720, 405), + }, + }); + + let pages = [curPage]; + for (const paragraph of sectionNode.children) { + const shapedTextList = shaping(ctx, paragraph.content!, viewModel, paragraph, sectionBreakConfig); + pages = lineBreaking(ctx, viewModel, shapedTextList, pages[pages.length - 1], paragraph, sectionBreakConfig, null); + } + + const page = pages[0]; + const sheet = page.skeDrawings.get('b1')!; + const base = page.skeDrawings.get('b2')!; + const slide = page.skeDrawings.get('b3')!; + + expect(sheet.height).toBe(19004); + expect(base.height).toBe(5156); + expect(slide.height).toBe(405); + expectDrawingInDocumentFlowOrder(pages, ['b1', 'b2', 'b3']); + }); + + it('keeps consecutive measured top-bottom custom blocks in document-flow order on finite pages', () => { + const heights: Record = { + b1: 19004, + b2: 5156, + b3: 405, + }; + setDocsCustomBlockRenderViewportProvider((_unitId, blockId, input) => { + const height = heights[blockId]; + if (height == null) { + return null; + } + + return { + contentHeight: height, + contentWidth: input.fallbackWidth, + height, + viewportHeight: Math.min(height, 923), + width: input.fallbackWidth, + }; + }); + + const contents = ['Embed host document', DataStreamTreeTokenType.CUSTOM_BLOCK, DataStreamTreeTokenType.CUSTOM_BLOCK, DataStreamTreeTokenType.CUSTOM_BLOCK]; + const { viewModel, ctx, sectionNode, sectionBreakConfig, curPage } = createSectionLayoutTestBed(contents, { + body: { + customBlocks: [ + { startIndex: 20, blockId: 'b1' }, + { startIndex: 22, blockId: 'b2' }, + { startIndex: 24, blockId: 'b3' }, + ], + }, + documentStyle: { + documentFlavor: DocumentFlavor.MODERN, + pageSize: { width: 1200, height: 960 }, + }, + drawings: { + b1: createTopBottomDrawing('b1', 960, 480), + b2: createTopBottomDrawing('b2', 960, 480), + b3: createTopBottomDrawing('b3', 720, 405), + }, + }); + + let pages = [curPage]; + for (const paragraph of sectionNode.children) { + const shapedTextList = shaping(ctx, paragraph.content!, viewModel, paragraph, sectionBreakConfig); + pages = lineBreaking(ctx, viewModel, shapedTextList, pages[pages.length - 1], paragraph, sectionBreakConfig, null); + } + + const drawings = pages.flatMap((page) => [...page.skeDrawings.values()]); + const lines = pages.flatMap((page) => page.sections.flatMap((section) => section.columns.flatMap((column) => column.lines))); + const sheet = drawings.find((drawing) => drawing.drawingId === 'b1')!; + const base = drawings.find((drawing) => drawing.drawingId === 'b2')!; + const slide = drawings.find((drawing) => drawing.drawingId === 'b3')!; + const slideLine = lines.find((line) => line.divides.some((divide) => divide.glyphGroup.some((glyph) => glyph.drawingId === 'b3')))!; + + expect(sheet.height).toBe(19004); + expect(base.height).toBe(5156); + expect(slide.height).toBe(405); + expect(slide.aTop).toBeGreaterThanOrEqual(slideLine.top); + expectDrawingInDocumentFlowOrder(pages, ['b1', 'b2', 'b3']); + }); + + it('does not collapse adjacent top-bottom custom blocks in the same paragraph', () => { + const heights: Record = { + b1: 300, + b2: 200, + b3: 100, + }; + setDocsCustomBlockRenderViewportProvider((_unitId, blockId, input) => { + const height = heights[blockId]; + if (height == null) { + return null; + } + + return { + contentHeight: height, + contentWidth: input.fallbackWidth, + height, + viewportHeight: height, + width: input.fallbackWidth, + }; + }); + + const { viewModel, ctx, paragraphNode, sectionBreakConfig, curPage } = createParagraphLayoutTestBed( + `${DataStreamTreeTokenType.CUSTOM_BLOCK}${DataStreamTreeTokenType.CUSTOM_BLOCK}${DataStreamTreeTokenType.CUSTOM_BLOCK}`, + { + body: { + customBlocks: [ + { startIndex: 0, blockId: 'b1' }, + { startIndex: 1, blockId: 'b2' }, + { startIndex: 2, blockId: 'b3' }, + ], + }, + documentStyle: { + documentFlavor: DocumentFlavor.MODERN, + pageSize: { width: 1200, height: Number.POSITIVE_INFINITY }, + }, + drawings: { + b1: createTopBottomDrawing('b1', 960, 480), + b2: createTopBottomDrawing('b2', 960, 480), + b3: createTopBottomDrawing('b3', 720, 405), + }, + } + ); + const shapedTextList = shaping(ctx, paragraphNode.content!, viewModel, paragraphNode, sectionBreakConfig); + + lineBreaking(ctx, viewModel, shapedTextList, curPage, paragraphNode, sectionBreakConfig, null); + + const sheet = curPage.skeDrawings.get('b1')!; + const base = curPage.skeDrawings.get('b2')!; + const slide = curPage.skeDrawings.get('b3')!; + + expect(sheet.height).toBe(300); + expect(base.aTop).toBeGreaterThanOrEqual(sheet.aTop + sheet.height); + expect(slide.aTop).toBeGreaterThanOrEqual(base.aTop + base.height); + }); + + it('overwrites stale measured top-bottom drawing positions during relayout', () => { + const heights: Record = { + b1: 19004, + b2: 5156, + b3: 405, + }; + setDocsCustomBlockRenderViewportProvider((_unitId, blockId, input) => { + const height = heights[blockId]; + if (height == null) { + return null; + } + + return { + contentHeight: height, + contentWidth: input.fallbackWidth, + height, + viewportHeight: Math.min(height, 923), + width: input.fallbackWidth, + }; + }); + + const contents = ['Embed host document', DataStreamTreeTokenType.CUSTOM_BLOCK, DataStreamTreeTokenType.CUSTOM_BLOCK, DataStreamTreeTokenType.CUSTOM_BLOCK]; + const { dataModel, viewModel, ctx, sectionNode, sectionBreakConfig, curPage } = createSectionLayoutTestBed(contents, { + body: { + customBlocks: [ + { startIndex: 20, blockId: 'b1' }, + { startIndex: 22, blockId: 'b2' }, + { startIndex: 24, blockId: 'b3' }, + ], + }, + documentStyle: { + documentFlavor: DocumentFlavor.MODERN, + pageSize: { width: 1200, height: 960 }, + }, + drawings: { + b1: createTopBottomDrawing('b1', 960, 480), + b2: createTopBottomDrawing('b2', 960, 480), + b3: createTopBottomDrawing('b3', 720, 405), + }, + }); + const originDrawings = dataModel.getSnapshot().drawings!; + curPage.skeDrawings.set('b1', createStaleTopBottomSkeleton('b1', originDrawings.b1, 19040, 19004)); + curPage.skeDrawings.set('b2', createStaleTopBottomSkeleton('b2', originDrawings.b2, 5204, 5156)); + curPage.skeDrawings.set('b3', createStaleTopBottomSkeleton('b3', originDrawings.b3, 465, 405)); + + let pages = [curPage]; + for (const paragraph of sectionNode.children) { + const shapedTextList = shaping(ctx, paragraph.content!, viewModel, paragraph, sectionBreakConfig); + pages = lineBreaking(ctx, viewModel, shapedTextList, pages[pages.length - 1], paragraph, sectionBreakConfig, null); + } + + const drawings = pages.flatMap((page) => [...page.skeDrawings.values()]); + const sheet = drawings.find((drawing) => drawing.drawingId === 'b1')!; + const base = drawings.find((drawing) => drawing.drawingId === 'b2')!; + const slide = drawings.find((drawing) => drawing.drawingId === 'b3')!; + + expect(sheet.height).toBe(19004); + expect(base.height).toBe(5156); + expect(slide.height).toBe(405); + expect(sheet.aTop).not.toBe(19040); + expect(base.aTop).not.toBe(5204); + expect(slide.aTop).not.toBe(465); + expectDrawingInDocumentFlowOrder(pages, ['b1', 'b2', 'b3']); + }); + + it('moves measured top-bottom custom block with preceding document-flow content', () => { + setDocsCustomBlockRenderViewportProvider((_unitId, blockId, input) => { + if (blockId !== 'b1') { + return null; + } + + return { + contentHeight: 640, + contentWidth: input.fallbackWidth, + height: 640, + viewportHeight: 640, + width: input.fallbackWidth, + }; + }); + + const layoutBlockTop = (contents: string[], blockParagraphIndex: number) => { + const startIndex = contents + .slice(0, blockParagraphIndex) + .reduce((index, content) => index + content.length + 1, 0); + const { viewModel, ctx, sectionNode, sectionBreakConfig, curPage } = createSectionLayoutTestBed(contents, { + body: { + customBlocks: [{ startIndex, blockId: 'b1' }], + }, + documentStyle: { + documentFlavor: DocumentFlavor.MODERN, + pageSize: { width: 1200, height: Number.POSITIVE_INFINITY }, + }, + drawings: { + b1: createTopBottomDrawing('b1', 960, 480), + }, + }); + + let pages = [curPage]; + for (const paragraph of sectionNode.children) { + const shapedTextList = shaping(ctx, paragraph.content!, viewModel, paragraph, sectionBreakConfig); + pages = lineBreaking(ctx, viewModel, shapedTextList, pages[pages.length - 1], paragraph, sectionBreakConfig, null); + } + + return pages[0].skeDrawings.get('b1')!.aTop; + }; + + const originalTop = layoutBlockTop(['Embed host document', DataStreamTreeTokenType.CUSTOM_BLOCK], 1); + const shiftedTop = layoutBlockTop(['Embed host document', 'Inserted paragraph before block', DataStreamTreeTokenType.CUSTOM_BLOCK], 2); + + expect(shiftedTop).toBeGreaterThan(originalTop); + }); }); + +function createTopBottomDrawing(drawingId: string, width: number, height: number) { + return { + drawingId, + layoutType: PositionedObjectLayoutType.WRAP_TOP_AND_BOTTOM, + docTransform: { + angle: 0, + positionH: { relativeFrom: ObjectRelativeFromH.COLUMN, posOffset: 0 }, + positionV: { relativeFrom: ObjectRelativeFromV.PARAGRAPH, posOffset: 0 }, + size: { width, height }, + }, + }; +} + +function expectDrawingInDocumentFlowOrder(pages: IDocumentSkeletonPage[], drawingIds: string[]) { + const positions = drawingIds.map((drawingId) => getDrawingLinePosition(pages, drawingId)); + + for (let i = 1; i < positions.length; i++) { + expect(compareDocumentFlowPosition(positions[i - 1], positions[i])).toBeLessThan(0); + } +} + +function getDrawingLinePosition(pages: IDocumentSkeletonPage[], drawingId: string) { + for (let pageIndex = 0; pageIndex < pages.length; pageIndex++) { + const page = pages[pageIndex]; + for (let sectionIndex = 0; sectionIndex < page.sections.length; sectionIndex++) { + const section = page.sections[sectionIndex]; + for (let columnIndex = 0; columnIndex < section.columns.length; columnIndex++) { + const column = section.columns[columnIndex]; + for (let lineIndex = 0; lineIndex < column.lines.length; lineIndex++) { + const line = column.lines[lineIndex]; + for (let divideIndex = 0; divideIndex < line.divides.length; divideIndex++) { + const divide = line.divides[divideIndex]; + const glyphIndex = divide.glyphGroup.findIndex((glyph) => glyph.drawingId === drawingId); + if (glyphIndex > -1) { + return { columnIndex, divideIndex, glyphIndex, lineIndex, pageIndex, sectionIndex }; + } + } + } + } + } + } + + throw new Error(`Missing custom block glyph for drawing "${drawingId}"`); +} + +function compareDocumentFlowPosition( + a: ReturnType, + b: ReturnType +) { + return ( + a.pageIndex - b.pageIndex || + a.sectionIndex - b.sectionIndex || + a.columnIndex - b.columnIndex || + a.lineIndex - b.lineIndex || + a.divideIndex - b.divideIndex || + a.glyphIndex - b.glyphIndex + ); +} + +function createStaleTopBottomSkeleton(drawingId: string, drawingOrigin: unknown, aTop: number, height: number) { + return { + aLeft: 0, + aTop, + angle: 0, + blockAnchorTop: aTop, + columnLeft: 0, + customBlockRenderViewport: { height, viewportHeight: Math.min(height, 923) }, + drawingId, + drawingOrigin, + height, + initialState: true, + isPageBreak: false, + lineHeight: 0, + lineTop: aTop, + width: 960, + } as never; +} diff --git a/packages/engine-render/src/components/docs/layout/block/paragraph/__tests__/shaping.spec.ts b/packages/engine-render/src/components/docs/layout/block/paragraph/__tests__/shaping.spec.ts index 233c84063fc5..d69f91b42503 100644 --- a/packages/engine-render/src/components/docs/layout/block/paragraph/__tests__/shaping.spec.ts +++ b/packages/engine-render/src/components/docs/layout/block/paragraph/__tests__/shaping.spec.ts @@ -274,6 +274,33 @@ describe('shaping', () => { } }); + it('keeps top-bottom custom block as an anchor glyph instead of occupying document flow', () => { + const content = `A${DataStreamTreeTokenType.CUSTOM_BLOCK}B`; + const { viewModel, ctx, paragraphNode, sectionBreakConfig } = createParagraphLayoutTestBed(content, { + body: { + customBlocks: [{ startIndex: 1, blockId: 'b1' }], + }, + drawings: { + b1: { + drawingId: 'd1', + layoutType: PositionedObjectLayoutType.WRAP_TOP_AND_BOTTOM, + docTransform: { + angle: 0, + size: { width: 100, height: 120 }, + }, + }, + }, + }); + + const result = shaping(ctx, paragraphNode.content!, viewModel, paragraphNode, sectionBreakConfig); + + const allGlyphs = result.flatMap((r) => r.glyphs); + const customBlockGlyph = allGlyphs.find((g) => g.streamType === DataStreamTreeTokenType.CUSTOM_BLOCK); + expect(customBlockGlyph).toBeDefined(); + expect(customBlockGlyph!.width).toBe(0); + expect(customBlockGlyph!.bBox.ba + customBlockGlyph!.bBox.bd).toBe(0); + }); + it('shapes text with useOpenType when font library is ready', () => { const { viewModel, ctx, paragraphNode, sectionBreakConfig } = createParagraphLayoutTestBed('Hello'); const originalIsReady = fontLibrary.isReady; diff --git a/packages/engine-render/src/components/docs/layout/block/paragraph/layout-ruler.ts b/packages/engine-render/src/components/docs/layout/block/paragraph/layout-ruler.ts index ce59ad44a6ee..27b223e5f096 100644 --- a/packages/engine-render/src/components/docs/layout/block/paragraph/layout-ruler.ts +++ b/packages/engine-render/src/components/docs/layout/block/paragraph/layout-ruler.ts @@ -33,6 +33,7 @@ import type { import { BooleanNumber, DataStreamTreeTokenType, + DocumentFlavor, GridType, NAMED_STYLE_SPACE_MAP, ObjectRelativeFromH, @@ -43,6 +44,7 @@ import { WrapStrategy, } from '@univerjs/core'; import { GlyphType, LineType } from '../../../../../basics/i-document-skeleton-cached'; +import { getDocsCustomBlockRenderViewport } from '../../../custom-block-render-viewport'; import { BreakPointType } from '../../line-breaker/break'; import { addGlyphToDivide, createSkeletonBulletGlyph } from '../../model/glyph'; import { @@ -486,13 +488,15 @@ function _divideOperator( if (anchorDrawings.length > 0) { const paragraphAnchorLeft = __getParagraphAnchorLeft(sectionBreakConfig, paragraphConfig, paragraphConfig.paragraphStyle?.indentStart); const drawings = __getDrawingPosition( + ctx, currentLine.top, currentLine.lineHeight, currentLine.parent, true, paragraphConfig.pDrawingAnchor?.get(paragraphConfig.paragraphIndex)?.top, anchorDrawings, - paragraphAnchorLeft + paragraphAnchorLeft, + false ); __updateDrawingPosition(currentLine.parent, drawings); addGlyphToDivide(divide, glyphGroup, preOffsetLeft); @@ -695,6 +699,7 @@ function _lineOperator( } let deferredInlineGroupAnchorDrawings: IDocumentSkeletonDrawing[] = []; + let deferredTopBottomAnchorDrawings: IDocumentSkeletonDrawing[] = []; if (paragraphNonInlineSkeDrawings != null && paragraphNonInlineSkeDrawings.size > 0) { let targetDrawings = [...paragraphNonInlineSkeDrawings.values()] @@ -707,15 +712,22 @@ function _lineOperator( ); targetDrawings = targetDrawings.filter((drawing) => !deferredInlineGroupAnchorDrawings.includes(drawing)); } + deferredTopBottomAnchorDrawings = targetDrawings.filter((drawing) => + glyphGroupCustomBlockIds.has(drawing.drawingId) && + drawing.drawingOrigin.layoutType === PositionedObjectLayoutType.WRAP_TOP_AND_BOTTOM + ); + targetDrawings = targetDrawings.filter((drawing) => + drawing.drawingOrigin.layoutType !== PositionedObjectLayoutType.WRAP_TOP_AND_BOTTOM + ); - __updateAndPositionDrawings(ctx, lineTop, lineHeight, column, targetDrawings, paragraphConfig.paragraphIndex, isParagraphFirstShapedText, pDrawingAnchor?.get(paragraphIndex)?.top, paragraphAnchorLeft); + __updateAndPositionDrawings(ctx, lineTop, lineHeight, column, targetDrawings, paragraphConfig.paragraphIndex, isParagraphFirstShapedText, pDrawingAnchor?.get(paragraphIndex)?.top, paragraphAnchorLeft, false, deferredTopBottomAnchorDrawings.length > 0); } if (skeTablesInParagraph != null && skeTablesInParagraph.length > 0) { needOpenNewPageByTableLayout = _updateAndPositionTable(ctx, lineTop, lineHeight, lastPage, column, section, skeTablesInParagraph, paragraphConfig.paragraphIndex, sectionBreakConfig, pDrawingAnchor?.get(paragraphIndex)?.top); } - const newLineTop = positionedCustomBlockOnly + const calculatedLineTop = positionedCustomBlockOnly ? lineTop : calculateLineTopByDrawings( lineHeight, @@ -724,6 +736,12 @@ function _lineOperator( headerPage, footerPage ); // WRAP_TOP_AND_BOTTOM drawing and WRAP NONE table will change the starting top of the line + const previousTopBottomCustomBlockFlowBottom = deferredTopBottomAnchorDrawings.length > 0 + ? paragraphConfig.topBottomCustomBlockFlowBottom + : undefined; + const newLineTop = previousTopBottomCustomBlockFlowBottom == null + ? calculatedLineTop + : Math.max(calculatedLineTop, previousTopBottomCustomBlockFlowBottom); const lineOverflowsSection = lineHeight + newLineTop - section.height > LINE_LAYOUT_OVERFLOW_TOLERANCE; @@ -802,7 +820,12 @@ function _lineOperator( column.lines.push(newLine); newLine.parent = column; - createAndUpdateBlockAnchor(paragraphIndex, newLine, lineTop, pDrawingAnchor); + const blockAnchorTop = deferredTopBottomAnchorDrawings.length > 0 ? newLineTop : lineTop; + createAndUpdateBlockAnchor(paragraphIndex, newLine, blockAnchorTop, pDrawingAnchor); + if (deferredTopBottomAnchorDrawings.length > 0) { + __updateAndPositionDrawings(ctx, newLineTop, lineHeight, column, deferredTopBottomAnchorDrawings, paragraphConfig.paragraphIndex, isParagraphFirstShapedText, blockAnchorTop, paragraphAnchorLeft, true, true); + __updateTopBottomCustomBlockFlowBottom(paragraphConfig, deferredTopBottomAnchorDrawings); + } _divideOperator( ctx, @@ -831,13 +854,15 @@ function __updateAndPositionDrawings( isParagraphFirstShapedText: boolean, drawingAnchorTop?: number, drawingAnchorLeft = 0, - skipRelayoutCheck = false + skipRelayoutCheck = false, + overwriteTopBottomPosition = false ) { if (targetDrawings.length === 0) { return; } const drawings = __getDrawingPosition( + ctx, lineTop, lineHeight, column, @@ -881,7 +906,8 @@ function __updateAndPositionDrawings( __updateDrawingPosition( column, - drawings + drawings, + overwriteTopBottomPosition ); } @@ -1119,6 +1145,27 @@ function _getCustomBlockIdsInLine(line: IDocumentSkeletonLine) { return customBlockIds; } +function __updateTopBottomCustomBlockFlowBottom( + paragraphConfig: IParagraphConfig, + drawings: IDocumentSkeletonDrawing[] +) { + for (const drawing of drawings) { + const { drawingOrigin } = drawing; + if ( + drawingOrigin.layoutType !== PositionedObjectLayoutType.WRAP_TOP_AND_BOTTOM || + drawingOrigin.behindDoc === BooleanNumber.TRUE + ) { + continue; + } + + const bottom = drawing.aTop + drawing.height + (drawingOrigin.distB ?? 0); + paragraphConfig.topBottomCustomBlockFlowBottom = Math.max( + paragraphConfig.topBottomCustomBlockFlowBottom ?? Number.NEGATIVE_INFINITY, + bottom + ); + } +} + function __isZeroWidthNonFlowFloatingAnchorLine( glyphGroup: IDocumentSkeletonGlyph[], paragraphNonInlineSkeDrawings?: Map @@ -1573,6 +1620,7 @@ export function getLineHeightMetrics( export function updateInlineDrawingPosition( line: IDocumentSkeletonLine, paragraphInlineSkeDrawings?: Map, + unitId = '', blockAnchorTop?: number, paragraphNonInlineSkeDrawings?: Map ) { @@ -1613,8 +1661,22 @@ export function updateInlineDrawingPosition( const { size, angle } = docTransform; const { width = 0, height = 0 } = size; const glyphHeight = glyph.bBox.bd + glyph.bBox.ba; - - drawing.aLeft = column.left + divide.left + divide.paddingLeft + glyph.left + 0.5 * glyph.width - 0.5 * width || 0; + const glyphLeft = divide.left + divide.paddingLeft + glyph.left; + const blockLeft = column.left + glyphLeft; + const viewport = getDocsCustomBlockRenderViewport(unitId, drawingId, { + blockLeft, + fallbackHeight: height, + fallbackWidth: width, + pageMarginLeft: page.marginLeft, + pageMarginRight: page.marginRight, + pageWidth: page.pageWidth, + }); + const drawingWidth = viewport?.width ?? width; + const drawingHeight = viewport?.height ?? height; + + drawing.aLeft = viewport + ? blockLeft + (viewport.offsetLeft ?? 0) + : blockLeft + 0.5 * glyph.width - 0.5 * drawingWidth || 0; if (glyph.width > divide.width) { for (const positionedDrawing of paragraphNonInlineSkeDrawings?.values() ?? []) { const positionedOrigin = positionedDrawing.drawingOrigin; @@ -1634,7 +1696,7 @@ export function updateInlineDrawingPosition( } const positionedRight = positionedDrawing.aLeft + positionedDrawing.width; - const drawingRight = drawing.aLeft + width; + const drawingRight = drawing.aLeft + drawingWidth; if (positionedDrawing.aLeft < drawingRight && positionedRight > drawing.aLeft) { drawing.aLeft = Math.max( drawing.aLeft, @@ -1643,10 +1705,20 @@ export function updateInlineDrawingPosition( } } } - drawing.aTop = lineTop + lineHeight - 0.5 * glyphHeight - 0.5 * height - marginBottom; - drawing.width = width; - drawing.height = height; + drawing.width = drawingWidth; + drawing.height = drawingHeight; + drawing.aTop = lineTop + lineHeight - 0.5 * glyphHeight - 0.5 * drawingHeight - marginBottom; drawing.angle = angle; + drawing.customBlockRenderViewport = viewport + ? { + bleedLeft: viewport.bleedLeft, + bleedWidth: viewport.bleedWidth, + contentHeight: viewport.contentHeight, + contentWidth: viewport.contentWidth, + height: viewport.height, + viewportHeight: viewport.viewportHeight, + } + : undefined; drawing.isPageBreak = isPageBreak; drawing.lineTop = lineTop; drawing.columnLeft = column.left; @@ -1662,13 +1734,15 @@ export function updateInlineDrawingPosition( } function __getDrawingPosition( + ctx: ILayoutContext, lineTop: number, lineHeight: number, column: IDocumentSkeletonColumn, isParagraphFirstShapedText: boolean, blockAnchorTop?: number, needPositionDrawings: IDocumentSkeletonDrawing[] = [], - blockAnchorLeft = 0 + blockAnchorLeft = 0, + normalizeTraditionalColumnAnchor = true ) { const page = column.parent?.parent; if ( @@ -1695,15 +1769,35 @@ function __getDrawingPosition( const { docTransform } = drawingOrigin; const { positionH, positionV, size, angle } = docTransform; - const { width = 0, height = 0 } = size; - - let aLeft = getPositionHorizon(positionH, column, page, width, isPageBreak) ?? 0; + const { width, height } = size; + const fallbackWidth = width ?? 0; + const fallbackHeight = height ?? 0; + const viewport = drawingOrigin.layoutType === PositionedObjectLayoutType.WRAP_TOP_AND_BOTTOM + ? getDocsCustomBlockRenderViewport(ctx.dataModel.getUnitId?.() ?? '', drawing.drawingId, { + fallbackHeight, + fallbackWidth, + pageMarginLeft: page.marginLeft, + pageMarginRight: page.marginRight, + pageWidth: page.pageWidth, + }) + : null; + const drawingWidth = viewport?.width ?? fallbackWidth; + const drawingHeight = viewport?.height ?? fallbackHeight; + + let aLeft = getPositionHorizon(positionH, column, page, drawingWidth, isPageBreak) ?? 0; if ( positionH.relativeFrom === ObjectRelativeFromH.COLUMN && blockAnchorLeft > 0 ) { const renderedColumnOrigin = isPageBreak ? 0 : (column.left || page.marginLeft); aLeft += blockAnchorLeft - renderedColumnOrigin; + if ( + normalizeTraditionalColumnAnchor && + ctx.dataModel.documentStyle.documentFlavor === DocumentFlavor.TRADITIONAL && + positionV.relativeFrom === ObjectRelativeFromV.PARAGRAPH + ) { + aLeft -= page.marginLeft; + } } drawing.aLeft = aLeft; drawing.aTop = getPositionVertical( @@ -1711,13 +1805,23 @@ function __getDrawingPosition( page, lineTop, lineHeight, - height, + drawingHeight, blockAnchorTop, isPageBreak ) ?? 0; - drawing.width = width; - drawing.height = height; + drawing.width = drawingWidth; + drawing.height = drawingHeight; drawing.angle = angle; + drawing.customBlockRenderViewport = viewport + ? { + bleedLeft: viewport.bleedLeft, + bleedWidth: viewport.bleedWidth, + contentHeight: viewport.contentHeight, + contentWidth: viewport.contentWidth, + height: viewport.height, + viewportHeight: viewport.viewportHeight, + } + : undefined; drawing.initialState = true; drawing.columnLeft = column.left; drawing.lineTop = lineTop; @@ -1734,7 +1838,8 @@ function __getDrawingPosition( // Update the absolute position of paragraphNonInlineSkeDrawings, relative to the first line layout of the paragraph function __updateDrawingPosition( column: IDocumentSkeletonColumn, - drawings?: Map + drawings?: Map, + overwriteTopBottomPosition = false ) { const page = column.parent?.parent; if (drawings == null || drawings.size === 0 || page == null) { @@ -1748,8 +1853,12 @@ function __updateDrawingPosition( // If it's a layout that splits the text up and down, // choose an image that is closer to the bottom for the layout. if (originDrawing.drawingOrigin.layoutType === PositionedObjectLayoutType.WRAP_TOP_AND_BOTTOM) { - const lowerDrawing = originDrawing.aTop > drawing.aTop ? originDrawing : drawing; - page.skeDrawings.set(drawing.drawingId, lowerDrawing); + if (overwriteTopBottomPosition) { + page.skeDrawings.set(drawing.drawingId, drawing); + } else { + const lowerDrawing = originDrawing.aTop > drawing.aTop ? originDrawing : drawing; + page.skeDrawings.set(drawing.drawingId, lowerDrawing); + } } else { page.skeDrawings.set(drawing.drawingId, drawing); } diff --git a/packages/engine-render/src/components/docs/layout/block/paragraph/linebreaking.ts b/packages/engine-render/src/components/docs/layout/block/paragraph/linebreaking.ts index cc35c2833b21..d049b0233e84 100644 --- a/packages/engine-render/src/components/docs/layout/block/paragraph/linebreaking.ts +++ b/packages/engine-render/src/components/docs/layout/block/paragraph/linebreaking.ts @@ -98,30 +98,102 @@ function _hasOnlyCustomBlockGlyphs(glyphs: IDocumentSkeletonGlyph[]): boolean { return glyphs.length > 0 && glyphs.every((glyph) => glyph.streamType === DataStreamTreeTokenType.CUSTOM_BLOCK); } -function _mergeAdjacentCustomBlockShapedTexts(shapedTextList: IShapedText[]): IShapedText[] { +function _mergeAdjacentCustomBlockShapedTexts( + shapedTextList: IShapedText[], + customBlockDrawings: Map +): IShapedText[] { const mergedShapedTextList: IShapedText[] = []; - for (const shapedText of shapedTextList) { - const lastShapedText = mergedShapedTextList[mergedShapedTextList.length - 1]; + for (const originShapedText of shapedTextList) { + const splitShapedTexts = _splitTopBottomCustomBlockShapedText(originShapedText, customBlockDrawings); - if ( - lastShapedText && - _hasOnlyCustomBlockGlyphs(lastShapedText.glyphs) && - _hasOnlyCustomBlockGlyphs(shapedText.glyphs) - ) { - lastShapedText.text += shapedText.text; - lastShapedText.glyphs.push(...shapedText.glyphs); - lastShapedText.breakPointType = shapedText.breakPointType; - continue; + for (const shapedText of splitShapedTexts) { + const lastShapedText = mergedShapedTextList[mergedShapedTextList.length - 1]; + + if ( + lastShapedText && + _hasOnlyCustomBlockGlyphs(lastShapedText.glyphs) && + _hasOnlyCustomBlockGlyphs(shapedText.glyphs) && + !_hasTopBottomCustomBlockGlyph(lastShapedText.glyphs, customBlockDrawings) && + !_hasTopBottomCustomBlockGlyph(shapedText.glyphs, customBlockDrawings) + ) { + lastShapedText.text += shapedText.text; + lastShapedText.glyphs.push(...shapedText.glyphs); + lastShapedText.breakPointType = shapedText.breakPointType; + continue; + } + + mergedShapedTextList.push({ + ...shapedText, + glyphs: [...shapedText.glyphs], + }); } + } - mergedShapedTextList.push({ + return mergedShapedTextList; +} + +function _splitTopBottomCustomBlockShapedText( + shapedText: IShapedText, + customBlockDrawings: Map +): IShapedText[] { + const splitShapedTexts: IShapedText[] = []; + let pendingGlyphs: IDocumentSkeletonGlyph[] = []; + let pendingText = ''; + let textOffset = 0; + + const flushPending = () => { + if (pendingGlyphs.length === 0) { + return; + } + + splitShapedTexts.push({ ...shapedText, - glyphs: [...shapedText.glyphs], + text: pendingText, + glyphs: pendingGlyphs, }); + pendingGlyphs = []; + pendingText = ''; + }; + + for (const glyph of shapedText.glyphs) { + const glyphText = shapedText.text.slice(textOffset, textOffset + glyph.count); + textOffset += glyph.count; + + if (_isTopBottomCustomBlockGlyph(glyph, customBlockDrawings)) { + flushPending(); + splitShapedTexts.push({ + ...shapedText, + text: glyphText, + glyphs: [glyph], + }); + continue; + } + + pendingGlyphs.push(glyph); + pendingText += glyphText; } - return mergedShapedTextList; + flushPending(); + return splitShapedTexts.length > 0 ? splitShapedTexts : [shapedText]; +} + +function _hasTopBottomCustomBlockGlyph( + glyphs: IDocumentSkeletonGlyph[], + customBlockDrawings: Map +): boolean { + return glyphs.some((glyph) => _isTopBottomCustomBlockGlyph(glyph, customBlockDrawings)); +} + +function _isTopBottomCustomBlockGlyph( + glyph: IDocumentSkeletonGlyph, + customBlockDrawings: Map +): boolean { + if (glyph.streamType !== DataStreamTreeTokenType.CUSTOM_BLOCK || glyph.drawingId == null) { + return false; + } + + return customBlockDrawings.get(glyph.drawingId)?.drawingOrigin.layoutType === PositionedObjectLayoutType.WRAP_TOP_AND_BOTTOM; } function _getListLevelAncestors( @@ -499,7 +571,7 @@ export function lineBreaking( let allPages = [curPage]; let isParagraphFirstShapedText = true; // First shaped text let shapedTextOffset = 0; - for (const [_index, { text, glyphs, breakPointType }] of _mergeAdjacentCustomBlockShapedTexts(shapedTextList).entries()) { + for (const [_index, { text, glyphs, breakPointType }] of _mergeAdjacentCustomBlockShapedTexts(shapedTextList, paragraphNonInlineSkeDrawingsByBlockId).entries()) { const textStartIndex = paragraphNode.startIndex + shapedTextOffset; const textGlyphCount = _glyphCount(glyphs); const textEndIndex = textStartIndex + textGlyphCount; diff --git a/packages/engine-render/src/components/docs/layout/block/paragraph/shaping.ts b/packages/engine-render/src/components/docs/layout/block/paragraph/shaping.ts index 2e5db75cde0b..3bfa306e3bb6 100644 --- a/packages/engine-render/src/components/docs/layout/block/paragraph/shaping.ts +++ b/packages/engine-render/src/components/docs/layout/block/paragraph/shaping.ts @@ -29,6 +29,7 @@ import { hasTibetan, startWithEmoji, } from '../../../../../basics/tools'; +import { getDocsCustomBlockRenderViewport } from '../../../custom-block-render-viewport'; import { Lang } from '../../hyphenation/lang'; import { LineBreaker } from '../../line-breaker'; import { BreakPointType } from '../../line-breaker/break'; @@ -241,8 +242,21 @@ export function shaping( const top = 0; const left = 0; const boundingBox = getBoundingBox(angle, left, width, top, height); + const viewport = getDocsCustomBlockRenderViewport( + viewModel.getDataModel().getUnitId?.() ?? '', + drawingOrigin.drawingId, + { + fallbackHeight: boundingBox.height ?? 0, + fallbackWidth: boundingBox.width ?? 0, + } + ); - newGlyph = createSkeletonCustomBlockGlyph(config, boundingBox.width, boundingBox.height, drawingOrigin.drawingId); + newGlyph = createSkeletonCustomBlockGlyph( + config, + viewport?.layoutWidth ?? viewport?.width ?? boundingBox.width, + viewport?.height ?? boundingBox.height, + drawingOrigin.drawingId + ); } else if (drawingOrigin != null) { newGlyph = createSkeletonCustomBlockGlyph(config, 0, 0, drawingOrigin.drawingId); } diff --git a/packages/engine-render/src/components/docs/layout/tools.ts b/packages/engine-render/src/components/docs/layout/tools.ts index 36da95a77a3d..9d3c4baa0203 100644 --- a/packages/engine-render/src/components/docs/layout/tools.ts +++ b/packages/engine-render/src/components/docs/layout/tools.ts @@ -567,7 +567,13 @@ export function updateInlineDrawingCoordsAndBorder(ctx: ILayoutContext, pages: I const drawingAnchor = ctx.skeletonResourceReference?.drawingAnchor?.get(segmentId)?.get(line.paragraphIndex); // Update inline drawings after the line is layout. if (affectInlineDrawings && affectInlineDrawings.size > 0) { - updateInlineDrawingPosition(line, affectInlineDrawings, drawingAnchor?.top, affectNonInlineDrawings); + updateInlineDrawingPosition( + line, + affectInlineDrawings, + ctx.dataModel.getUnitId?.() ?? '', + drawingAnchor?.top, + affectNonInlineDrawings + ); } const paragraphStyle = paragraphConfig?.paragraphStyle; diff --git a/packages/engine-render/src/index.ts b/packages/engine-render/src/index.ts index 6c4ae39db038..d9c9c7f29e0f 100644 --- a/packages/engine-render/src/index.ts +++ b/packages/engine-render/src/index.ts @@ -19,6 +19,8 @@ export * from './basics'; export { getOffsetRectForDom } from './basics/position'; export * from './canvas'; export * from './components'; +export type { DocsCustomBlockRenderViewportProvider, IDocsCustomBlockRenderViewport, IDocsCustomBlockRenderViewportInput } from './components/docs/custom-block-render-viewport'; +export { getDocsCustomBlockRenderViewport, setDocsCustomBlockRenderViewportProvider } from './components/docs/custom-block-render-viewport'; export { DocBackground } from './components/docs/doc-background'; export { Documents } from './components/docs/document'; export type { IPageRenderConfig } from './components/docs/document'; diff --git a/packages/engine-render/src/render-manager/__tests__/render-manager.service.spec.ts b/packages/engine-render/src/render-manager/__tests__/render-manager.service.spec.ts index 04dd7e2c4a83..5a191b4682da 100644 --- a/packages/engine-render/src/render-manager/__tests__/render-manager.service.spec.ts +++ b/packages/engine-render/src/render-manager/__tests__/render-manager.service.spec.ts @@ -154,4 +154,64 @@ describe('render manager service', () => { disposableB.dispose(); service.dispose(); }); + + it('deduplicates render dependencies by identifier', () => { + const darkMode$ = new Subject(); + const injector = { + createInstance: vi.fn(() => new Engine()), + } as unknown as Injector; + const instanceService = { + getUnit: vi.fn(() => null), + getUnitType: vi.fn(() => UniverInstanceType.UNIVER_SHEET), + getCurrentUnitOfType: vi.fn(() => null), + } as any; + const service = new RenderManagerService(injector, instanceService, { darkMode$ } as any); + const token = Symbol('render-dep') as any; + const otherToken = Symbol('other-render-dep') as any; + const firstDep = [token, { useClass: class FirstRenderModule {} }] as any; + const duplicateDep = [token, { useClass: class SecondRenderModule {} }] as any; + + const firstDisposable = service.registerRenderModule(UniverInstanceType.UNIVER_SHEET, firstDep); + const duplicateDisposable = service.registerRenderModule(UniverInstanceType.UNIVER_SHEET, duplicateDep); + const mixedDisposable = service.registerRenderModules(UniverInstanceType.UNIVER_SHEET, [ + duplicateDep, + otherToken, + ]); + + const dependencies = (service as any)._renderDependencies.get(UniverInstanceType.UNIVER_SHEET); + expect(dependencies).toEqual([firstDep, otherToken]); + + firstDisposable.dispose(); + duplicateDisposable.dispose(); + mixedDisposable.dispose(); + expect((service as any)._renderDependencies.get(UniverInstanceType.UNIVER_SHEET)).toEqual([]); + service.dispose(); + }); + + it('deduplicates identifier decorators by stable name across module copies', () => { + const darkMode$ = new Subject(); + const injector = { + createInstance: vi.fn(() => new Engine()), + } as unknown as Injector; + const instanceService = { + getUnit: vi.fn(() => null), + getUnitType: vi.fn(() => UniverInstanceType.UNIVER_SHEET), + getCurrentUnitOfType: vi.fn(() => null), + } as any; + const service = new RenderManagerService(injector, instanceService, { darkMode$ } as any); + const tokenA = Object.assign(() => undefined, { decoratorName: 'univer.sheet.selection-render-service' }) as any; + const tokenB = Object.assign(() => undefined, { decoratorName: 'univer.sheet.selection-render-service' }) as any; + const firstDep = [tokenA, { useClass: class FirstRenderModule {} }] as any; + const duplicateDep = [tokenB, { useClass: class SecondRenderModule {} }] as any; + + const firstDisposable = service.registerRenderModule(UniverInstanceType.UNIVER_SHEET, firstDep); + const duplicateDisposable = service.registerRenderModule(UniverInstanceType.UNIVER_SHEET, duplicateDep); + + expect((service as any)._renderDependencies.get(UniverInstanceType.UNIVER_SHEET)).toEqual([firstDep]); + + firstDisposable.dispose(); + duplicateDisposable.dispose(); + expect((service as any)._renderDependencies.get(UniverInstanceType.UNIVER_SHEET)).toEqual([]); + service.dispose(); + }); }); diff --git a/packages/engine-render/src/render-manager/__tests__/render-unit.spec.ts b/packages/engine-render/src/render-manager/__tests__/render-unit.spec.ts index bcd988b37b64..5c1277e6301a 100644 --- a/packages/engine-render/src/render-manager/__tests__/render-unit.spec.ts +++ b/packages/engine-render/src/render-manager/__tests__/render-unit.spec.ts @@ -39,6 +39,37 @@ class RenderModuleB extends Disposable implements IRenderModule { } } +class RenderModuleC extends Disposable implements IRenderModule { + static calls = 0; + + constructor(readonly context: IRenderContext) { + super(); + RenderModuleC.calls += 1; + } +} + +class EarlyResolvingRenderModule extends Disposable implements IRenderModule { + static resolve?: () => void; + + constructor(readonly context: IRenderContext) { + super(); + EarlyResolvingRenderModule.resolve?.(); + } +} + +class ReentrantRenderModule extends Disposable implements IRenderModule { + static calls = 0; + static renderUnit: RenderUnit | undefined; + static token: unknown; + static resolvedDuringConstruction: unknown; + + constructor(readonly context: IRenderContext) { + super(); + ReentrantRenderModule.calls += 1; + ReentrantRenderModule.resolvedDuringConstruction = ReentrantRenderModule.renderUnit?.with(ReentrantRenderModule.token as never); + } +} + function createRenderUnit(createUnitOptions?: any) { const parentInjector = new Injector(); const unit = { @@ -74,6 +105,22 @@ describe('render unit', () => { renderUnit.dispose(); }); + it('keeps render dependencies accessible while dispose deactivates subscribers', () => { + const renderUnit = createRenderUnit(); + renderUnit.addRenderDependencies([RenderModuleA as any] as any); + const resolvedDuringDeactivate: RenderModuleA[] = []; + const sub = renderUnit.activated$.subscribe((active) => { + if (!active) { + resolvedDuringDeactivate.push(renderUnit.with(RenderModuleA)); + } + }); + + expect(() => renderUnit.dispose()).not.toThrow(); + expect(resolvedDuringDeactivate).toHaveLength(1); + + sub.unsubscribe(); + }); + it('registers render dependencies by class and useClass mapping', () => { RenderModuleA.calls = 0; RenderModuleB.calls = 0; @@ -100,6 +147,115 @@ describe('render unit', () => { expect(renderUnit.components.size).toBe(0); }); + it('deduplicates identifier decorators before adding render dependencies', () => { + RenderModuleB.calls = 0; + const renderUnit = createRenderUnit(); + const tokenA = Object.assign(() => undefined, { decoratorName: 'univer.sheet.selection-render-service' }); + const tokenB = Object.assign(() => undefined, { decoratorName: 'univer.sheet.selection-render-service' }); + + renderUnit.addRenderDependencies([ + [tokenA, { useClass: RenderModuleB }], + [tokenB, { useClass: RenderModuleB }], + ] as any); + + expect(RenderModuleB.calls).toBe(1); + expect(renderUnit.with(tokenA as never)).toBeInstanceOf(RenderModuleB); + + renderUnit.dispose(); + }); + + it('does not initialize a render dependency twice when another module resolves it early', () => { + RenderModuleC.calls = 0; + const renderUnit = createRenderUnit(); + const tokenA = Object.assign(() => undefined, { decoratorName: 'univer.sheet.selection-render-service' }); + const tokenB = Object.assign(() => undefined, { decoratorName: 'univer.sheet.selection-render-service' }); + EarlyResolvingRenderModule.resolve = () => { + expect(renderUnit.with(tokenA as never)).toBeInstanceOf(RenderModuleC); + }; + + expect(() => renderUnit.addRenderDependencies([ + EarlyResolvingRenderModule, + [tokenB, { useClass: RenderModuleC }], + [tokenA, { useClass: RenderModuleC }], + ] as any)).not.toThrow(); + + expect(RenderModuleC.calls).toBe(1); + expect(renderUnit.with(tokenA as never)).toBeInstanceOf(RenderModuleC); + + EarlyResolvingRenderModule.resolve = undefined; + renderUnit.dispose(); + }); + + it('does not re-enter the same render dependency while it is being constructed', () => { + ReentrantRenderModule.calls = 0; + ReentrantRenderModule.resolvedDuringConstruction = undefined; + const renderUnit = createRenderUnit(); + const token = Object.assign(() => undefined, { decoratorName: 'univer.sheet.selection-render-service' }); + ReentrantRenderModule.renderUnit = renderUnit; + ReentrantRenderModule.token = token; + + expect(() => renderUnit.addRenderDependencies([ + [token, { useClass: ReentrantRenderModule }], + ] as any)).not.toThrow(); + + expect(ReentrantRenderModule.calls).toBe(1); + expect(ReentrantRenderModule.resolvedDuringConstruction).toBeUndefined(); + expect(renderUnit.with(token as never)).toBeInstanceOf(ReentrantRenderModule); + + ReentrantRenderModule.renderUnit = undefined; + ReentrantRenderModule.token = undefined; + renderUnit.dispose(); + }); + + it('resolves render dependencies from the render unit injector itself', () => { + RenderModuleB.calls = 0; + const parentInjector = new Injector(); + const token = Object.assign(() => undefined, { decoratorName: 'univer.sheet.selection-render-service' }) as any; + parentInjector.add([token, { useValue: 'parent-a' }]); + parentInjector.add([token, { useValue: 'parent-b' }]); + const unit = { + getUnitId: () => 'unit-1', + type: UniverInstanceType.UNIVER_SHEET, + } as any; + const renderUnit = parentInjector.createInstance(RenderUnit, { + engine: {} as any, + scene: {} as any, + isMainScene: true, + unit, + }); + + renderUnit.addRenderDependencies([ + [token, { useClass: RenderModuleB }], + ] as any); + + expect(RenderModuleB.calls).toBe(1); + expect(renderUnit.with(token)).toBeInstanceOf(RenderModuleB); + + renderUnit.dispose(); + }); + + it('derives render dependencies from explicit render parent injector', () => { + const rootInjector = new Injector(); + const renderParentInjector = rootInjector.createChild(); + const token = Object.assign(() => undefined, { decoratorName: 'univer.test.render-parent-token' }) as any; + renderParentInjector.add([token, { useValue: 'render-parent-value' }]); + const unit = { + getUnitId: () => 'unit-1', + type: UniverInstanceType.UNIVER_SHEET, + } as any; + const renderUnit = rootInjector.createInstance(RenderUnit, { + engine: {} as any, + scene: {} as any, + isMainScene: true, + unit, + createUnitOptions: { renderParentInjector }, + }); + + expect(renderUnit.getInjector().get(token)).toBe('render-parent-value'); + + renderUnit.dispose(); + }); + it('exposes mutable render context state for scene lifecycle coordination', () => { const renderUnit = createRenderUnit({ makeCurrent: false }); const states: boolean[] = []; diff --git a/packages/engine-render/src/render-manager/render-manager.service.ts b/packages/engine-render/src/render-manager/render-manager.service.ts index e04c59600a0d..0188feeb6bee 100644 --- a/packages/engine-render/src/render-manager/render-manager.service.ts +++ b/packages/engine-render/src/render-manager/render-manager.service.ts @@ -148,17 +148,18 @@ export class RenderManagerService extends Disposable implements IRenderManagerSe } const dependencies = this._renderDependencies.get(type)!; - dependencies.push(...deps); + const registeredDeps = deps.filter((dep) => !hasRenderDependency(dependencies, dep)); + dependencies.push(...registeredDeps); for (const [_, render] of this._renderMap) { const renderType = render.type; if (renderType === type) { - this._tryAddRenderDependencies(render, deps); + this._tryAddRenderDependencies(render, registeredDeps); } } return toDisposable(() => { - deps.forEach((dep) => remove(dependencies, dep)); + registeredDeps.forEach((dep) => remove(dependencies, dep)); }); } @@ -173,6 +174,10 @@ export class RenderManagerService extends Disposable implements IRenderManagerSe } const dependencies = this._renderDependencies.get(type)!; + if (hasRenderDependency(dependencies, depCtor)) { + return toDisposable(() => {}); + } + dependencies.push(depCtor); for (const [_, render] of this._renderMap) { @@ -215,7 +220,8 @@ export class RenderManagerService extends Disposable implements IRenderManagerSe * @returns renderUnit:IRender */ createRender(unitId: string, createUnitOptions?: ICreateUnitOptions): IRender { - const renderer = this._createRender(unitId, this._injector.createInstance(Engine, unitId, undefined), true, createUnitOptions); + const parentInjector = createUnitOptions?.renderParentInjector ?? this._injector; + const renderer = this._createRender(unitId, parentInjector.createInstance(Engine, unitId, undefined), createUnitOptions?.embeddedRender !== true, createUnitOptions, parentInjector); this._renderCreated$.next(renderer); return renderer; } @@ -250,7 +256,7 @@ export class RenderManagerService extends Disposable implements IRenderManagerSe * @param isMainScene * @returns renderUnit:IRender */ - protected _createRender(unitId: string, engine: Engine, isMainScene: boolean = true, createUnitOptions?: ICreateUnitOptions): IRender { + protected _createRender(unitId: string, engine: Engine, isMainScene: boolean = true, createUnitOptions?: ICreateUnitOptions, parentInjector: Injector = this._injector): IRender { const existItem = this.getRenderById(unitId); let shouldDestroyEngine = true; @@ -277,7 +283,7 @@ export class RenderManagerService extends Disposable implements IRenderManagerSe const type = this._univerInstanceService.getUnitType(unitId); const ctorOfDeps = this._getRenderDepsByType(type); - renderUnit = this._injector.createInstance(RenderUnit, { + renderUnit = parentInjector.createInstance(RenderUnit, { unit, engine, scene, @@ -286,8 +292,17 @@ export class RenderManagerService extends Disposable implements IRenderManagerSe }); this._addRenderUnit(unitId, renderUnit); - // init deps - this._tryAddRenderDependencies(renderUnit, ctorOfDeps); + try { + // init deps + this._tryAddRenderDependencies(renderUnit, ctorOfDeps); + } catch (error) { + try { + this._disposeItem(renderUnit); + } finally { + this._renderMap.delete(unitId); + } + throw error; + } } else { // For slide pages renderUnit = { @@ -372,6 +387,25 @@ export class RenderManagerService extends Disposable implements IRenderManagerSe } } +function hasRenderDependency(dependencies: Dependency[], dep: Dependency): boolean { + const identifier = getRenderDependencyIdentifier(dep); + const key = getRenderDependencyIdentifierKey(identifier); + return dependencies.some((registered) => getRenderDependencyIdentifierKey(getRenderDependencyIdentifier(registered)) === key); +} + +function getRenderDependencyIdentifier(dep: Dependency): DependencyIdentifier { + return (Array.isArray(dep) ? dep[0] : dep) as DependencyIdentifier; +} + +function getRenderDependencyIdentifierKey(identifier: DependencyIdentifier): DependencyIdentifier | string { + const decoratorName = (identifier as unknown as { decoratorName?: unknown }).decoratorName; + if (typeof decoratorName === 'string' && decoratorName) { + return `identifier:${decoratorName}`; + } + + return identifier; +} + export const IRenderManagerService = createIdentifier('engine-render.render-manager.service'); export function isDisposable(thing: unknown): thing is IDisposable { diff --git a/packages/engine-render/src/render-manager/render-unit.ts b/packages/engine-render/src/render-manager/render-unit.ts index 8233f0a9a91b..248ea2f9d645 100644 --- a/packages/engine-render/src/render-manager/render-unit.ts +++ b/packages/engine-render/src/render-manager/render-unit.ts @@ -27,7 +27,7 @@ import type { Observable } from 'rxjs'; import type { Engine } from '../engine'; import type { Scene } from '../scene'; import type { RenderComponentType } from './render-manager.service'; -import { Disposable, Inject, Injector, isClassDependencyItem } from '@univerjs/core'; +import { Disposable, Inject, Injector, isClassDependencyItem, LookUp } from '@univerjs/core'; import { BehaviorSubject, distinctUntilChanged } from 'rxjs'; /** @@ -52,6 +52,7 @@ export interface IRender { activated$: Observable; with(dependency: DependencyIdentifier): T; + getInjector?(): Injector; getRenderContext?(): IRenderContext; /** * Deactivate the render unit, means the render unit would be freezed and not updated, @@ -97,6 +98,7 @@ export class RenderUnit extends Disposable implements IRender { private readonly _injector: Injector; private _renderContext: IRenderContext; + private readonly _dependencyService: RenderUnitDependencyService; set isMainScene(is: boolean) { this._renderContext.isMainScene = is; } get isMainScene(): boolean { return this._renderContext.isMainScene; } @@ -114,7 +116,12 @@ export class RenderUnit extends Disposable implements IRender { ) { super(); - this._injector = parentInjector.createChild(); + const renderParentInjector = init.createUnitOptions?.renderParentInjector ?? parentInjector; + this._injector = renderParentInjector.createChild(); + this._dependencyService = new RenderUnitDependencyService( + this._injector, + () => this._renderContext + ); this._renderContext = { unit: init.unit, @@ -136,13 +143,15 @@ export class RenderUnit extends Disposable implements IRender { } override dispose(): void { - this._injector.dispose(); - - super.dispose(); - + if (this._disposed) { + return; + } this._activated$.next(false); this._activated$.complete(); + super.dispose(); + this._injector.dispose(); + //@ts-ignore this._renderContext.unit = null; this._renderContext.components.clear(); @@ -156,7 +165,11 @@ export class RenderUnit extends Disposable implements IRender { * Get a dependency from the RenderUnit's injector. */ with(dependency: DependencyIdentifier): T { - return this._injector.get(dependency); + return this._dependencyService.resolve(dependency); + } + + getInjector(): Injector { + return this._injector; } /** @@ -168,27 +181,8 @@ export class RenderUnit extends Disposable implements IRender { } private _initDependencies(dependencies: Dependency[]): void { - const j = this._injector; - - dependencies.forEach((dep) => { - const [identifier, implOrNull] = Array.isArray(dep) ? dep : [dep, null]; - - if (!implOrNull) { - j.add([identifier, { - useFactory: (): IRenderModule => j.createInstance(identifier, this._renderContext), - }]); - } else if (isClassDependencyItem(implOrNull)) { - j.add([identifier, { - useFactory: (): IRenderModule => j.createInstance(implOrNull.useClass, this._renderContext), - }]); - } else { - throw new Error('[RenderUnit]: render dependency could only be an class!'); - } - }); - - dependencies.forEach((dep) => { - const [identifier] = Array.isArray(dep) ? dep : [dep, null]; - j.get(identifier); + this._dependencyService.register(dependencies).forEach((record) => { + this._dependencyService.resolveRecord(record); }); } @@ -204,3 +198,139 @@ export class RenderUnit extends Disposable implements IRender { this._renderContext.deactivate(); } } + +interface IRenderDependencyRecord { + key: unknown; + identifier: DependencyIdentifier; + create: () => IRenderModule; +} + +class RenderUnitDependencyService { + private readonly _records = new Map(); + private readonly _resolved = new Map(); + private readonly _resolving = new Set(); + + constructor( + private readonly _injector: Injector, + private readonly _getRenderContext: () => IRenderContext + ) { } + + register(dependencies: Dependency[]): IRenderDependencyRecord[] { + const records: IRenderDependencyRecord[] = []; + const seen = new Set(); + + dependencies.forEach((dependency) => { + const parsed = this._parseDependency(dependency); + const key = getRenderDependencyIdentifierKey(parsed.identifier); + const existing = this._records.get(key); + const record = existing ?? this._addRecord(key, parsed.identifier, parsed.create); + if (seen.has(record.key)) { + return; + } + + seen.add(record.key); + records.push(record); + }); + + return records; + } + + resolve(dependency: DependencyIdentifier): T { + const key = getRenderDependencyIdentifierKey(dependency); + if (this._resolved.has(key)) { + return this._resolved.get(key) as T; + } + + if (this._resolving.has(key)) { + return undefined as T; + } + + const record = this._records.get(key); + if (record) { + return this.resolveRecord(record) as T; + } + + return this._injector.get(dependency, LookUp.SELF); + } + + resolveRecord(record: IRenderDependencyRecord): unknown { + if (this._resolved.has(record.key)) { + return this._resolved.get(record.key); + } + + if (this._resolving.has(record.key)) { + return undefined; + } + + this._resolving.add(record.key); + try { + return this._injector.get(record.identifier, LookUp.SELF); + } finally { + this._resolving.delete(record.key); + } + } + + private _addRecord( + key: unknown, + identifier: DependencyIdentifier, + create: () => IRenderModule + ): IRenderDependencyRecord { + const record: IRenderDependencyRecord = { key, identifier, create }; + this._records.set(key, record); + this._injector.add([identifier, { + useFactory: () => this._create(record), + }]); + + return record; + } + + private _create(record: IRenderDependencyRecord): IRenderModule { + if (this._resolved.has(record.key)) { + return this._resolved.get(record.key) as IRenderModule; + } + + const alreadyResolving = this._resolving.has(record.key); + if (!alreadyResolving) { + this._resolving.add(record.key); + } + + try { + const instance = record.create(); + this._resolved.set(record.key, instance); + return instance; + } finally { + if (!alreadyResolving) { + this._resolving.delete(record.key); + } + } + } + + private _parseDependency(dependency: Dependency): Pick { + const [identifier, implOrNull] = Array.isArray(dependency) ? dependency : [dependency, null]; + + if (!implOrNull) { + return { + identifier, + create: () => this._injector.createInstance(identifier, this._getRenderContext()), + }; + } + + if (isClassDependencyItem(implOrNull)) { + return { + identifier, + create: () => this._injector.createInstance(implOrNull.useClass, this._getRenderContext()), + }; + } + + throw new Error('[RenderUnit]: render dependency could only be an class!'); + } +} + +function getRenderDependencyIdentifierKey(identifier: unknown): unknown { + const decoratorName = (identifier as { decoratorName?: unknown } | undefined)?.decoratorName; + if (typeof decoratorName === 'string' && decoratorName) { + return `identifier:${decoratorName}`; + } + + return identifier; +} diff --git a/packages/find-replace/src/__tests__/create-test-bed.ts b/packages/find-replace/src/__tests__/create-test-bed.ts index b7d6429feac6..4bcf8a584e7d 100644 --- a/packages/find-replace/src/__tests__/create-test-bed.ts +++ b/packages/find-replace/src/__tests__/create-test-bed.ts @@ -35,7 +35,6 @@ import { } from '@univerjs/core'; import { IMessageService } from '@univerjs/ui'; import enUS from '../locale/en-US'; -import { IFindReplaceService } from '../services/find-replace.service'; const TEST_WORKBOOK_DATA_DEMO: IWorkbookData = { id: 'test', @@ -125,4 +124,4 @@ export function createTestBed(workbookData?: IWorkbookData, dependencies?: Depen }; } -export { IFindReplaceService, IMessageService, TestMessageService }; +export { TestMessageService }; diff --git a/packages/find-replace/src/commands/commands/__tests__/replace.command.spec.ts b/packages/find-replace/src/commands/commands/__tests__/replace.command.spec.ts index 1c8f1d9c129e..36f010bcfe8a 100644 --- a/packages/find-replace/src/commands/commands/__tests__/replace.command.spec.ts +++ b/packages/find-replace/src/commands/commands/__tests__/replace.command.spec.ts @@ -18,13 +18,10 @@ import type { Injector, Univer } from '@univerjs/core'; import type { TestMessageService } from '../../../__tests__/create-test-bed'; import { ICommandService, IConfirmService } from '@univerjs/core'; import { MessageType } from '@univerjs/design'; +import { IMessageService } from '@univerjs/ui'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { - createTestBed, - IFindReplaceService, - IMessageService, -} from '../../../__tests__/create-test-bed'; -import { FindReplaceService } from '../../../services/find-replace.service'; +import { createTestBed } from '../../../__tests__/create-test-bed'; +import { FindReplaceService, IFindReplaceService } from '../../../services/find-replace.service'; import { ReplaceAllMatchesCommand, ReplaceCurrentMatchCommand } from '../replace.command'; describe('replace.command', () => { diff --git a/packages/find-replace/src/commands/operations/__tests__/find-replace.operation.spec.ts b/packages/find-replace/src/commands/operations/__tests__/find-replace.operation.spec.ts index 4b1a7a1cf061..4f3008bba22c 100644 --- a/packages/find-replace/src/commands/operations/__tests__/find-replace.operation.spec.ts +++ b/packages/find-replace/src/commands/operations/__tests__/find-replace.operation.spec.ts @@ -18,8 +18,8 @@ import type { Injector, Univer } from '@univerjs/core'; import type { IFindReplaceProvider } from '../../../services/find-replace.service'; import { ICommandService } from '@univerjs/core'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { createTestBed, IFindReplaceService } from '../../../__tests__/create-test-bed'; -import { FindReplaceService } from '../../../services/find-replace.service'; +import { createTestBed } from '../../../__tests__/create-test-bed'; +import { FindReplaceService, IFindReplaceService } from '../../../services/find-replace.service'; import { FocusSelectionOperation, GoToNextMatchOperation, diff --git a/packages/find-replace/src/views/dialog/__tests__/FindReplaceDialog.spec.tsx b/packages/find-replace/src/views/dialog/__tests__/FindReplaceDialog.spec.tsx index 231979c4d091..d7cbf7bf979e 100644 --- a/packages/find-replace/src/views/dialog/__tests__/FindReplaceDialog.spec.tsx +++ b/packages/find-replace/src/views/dialog/__tests__/FindReplaceDialog.spec.tsx @@ -25,12 +25,12 @@ import type { IReplaceAllResult, } from '../../../services/find-replace.service'; import { awaitTime, ICommandService, toDisposable } from '@univerjs/core'; -import { ILayoutService, RediContext } from '@univerjs/ui'; +import { ILayoutService, IMessageService, RediContext } from '@univerjs/ui'; import { act } from 'react'; import { createRoot } from 'react-dom/client'; import { BehaviorSubject, Subject } from 'rxjs'; import { afterEach, describe, expect, it } from 'vitest'; -import { createTestBed, IMessageService } from '../../../__tests__/create-test-bed'; +import { createTestBed } from '../../../__tests__/create-test-bed'; import { ReplaceAllMatchesCommand, ReplaceCurrentMatchCommand, diff --git a/packages/sheets-data-validation-ui/src/views/components/__tests__/DataValidationDetail.spec.tsx b/packages/sheets-data-validation-ui/src/views/components/__tests__/DataValidationDetail.spec.tsx index f8e7e186ca99..b4317238b080 100644 --- a/packages/sheets-data-validation-ui/src/views/components/__tests__/DataValidationDetail.spec.tsx +++ b/packages/sheets-data-validation-ui/src/views/components/__tests__/DataValidationDetail.spec.tsx @@ -75,6 +75,7 @@ import { WorksheetProtectionRuleModel, } from '@univerjs/sheets'; import { + BASE_FORMULA_INPUT_NAME, CheckboxValidator, DataValidationCacheService, DataValidationCustomFormulaService, @@ -82,6 +83,7 @@ import { DataValidationFormulaService, DataValidationListCacheService, DateValidator, + LIST_FORMULA_INPUT_NAME, ListMultipleValidator, ListValidator, RemoveSheetDataValidationCommand, @@ -110,7 +112,7 @@ import { afterEach, describe, expect, it } from 'vitest'; import { DataValidationPanelService } from '../../../services/data-validation-panel.service'; import { DataValidationDetail } from '../DataValidationDetail'; import { DateShowTimeOption } from '../DateShowTimeOption'; -import { BASE_FORMULA_INPUT_NAME, FORMULA_INPUTS, LIST_FORMULA_INPUT_NAME } from '../formula-input'; +import { FORMULA_INPUTS } from '../formula-input'; (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; diff --git a/packages/sheets-data-validation-ui/src/views/components/formula-input/index.ts b/packages/sheets-data-validation-ui/src/views/components/formula-input/index.ts index 987d9d760f6b..53641a4c3f97 100644 --- a/packages/sheets-data-validation-ui/src/views/components/formula-input/index.ts +++ b/packages/sheets-data-validation-ui/src/views/components/formula-input/index.ts @@ -26,13 +26,6 @@ import { CheckboxFormulaInput } from './CheckboxFormulaInput'; import { CustomFormulaInput } from './CustomFormulaInput'; import { ListFormulaInput } from './ListFormulaInput'; -export { - BASE_FORMULA_INPUT_NAME, - CHECKBOX_FORMULA_INPUT_NAME, - CUSTOM_FORMULA_INPUT_NAME, - LIST_FORMULA_INPUT_NAME, -}; - export const FORMULA_INPUTS: [string, FormulaInputType][] = [ [ CUSTOM_FORMULA_INPUT_NAME, diff --git a/packages/sheets-data-validation-ui/src/views/validator-views/__tests__/validator-views.spec.ts b/packages/sheets-data-validation-ui/src/views/validator-views/__tests__/validator-views.spec.ts index 5a7026f80d47..6d94cf9ea895 100644 --- a/packages/sheets-data-validation-ui/src/views/validator-views/__tests__/validator-views.spec.ts +++ b/packages/sheets-data-validation-ui/src/views/validator-views/__tests__/validator-views.spec.ts @@ -15,18 +15,26 @@ */ import type { IFormulaResult } from '@univerjs/data-validation'; -import { DataValidationType, ICommandService, Injector, IUniverInstanceService, LocaleService, ThemeService } from '@univerjs/core'; +import { + DataValidationType, + ICommandService, + Injector, + IUniverInstanceService, + LocaleService, + ThemeService, +} from '@univerjs/core'; import { DataValidatorDropdownType } from '@univerjs/data-validation'; import { IRenderManagerService } from '@univerjs/engine-render'; -import { DataValidationFormulaService, SheetDataValidationModel } from '@univerjs/sheets-data-validation'; -import { describe, expect, it } from 'vitest'; -import { DateShowTimeOption } from '../../components/DateShowTimeOption'; import { BASE_FORMULA_INPUT_NAME, CHECKBOX_FORMULA_INPUT_NAME, CUSTOM_FORMULA_INPUT_NAME, + DataValidationFormulaService, LIST_FORMULA_INPUT_NAME, -} from '../../components/formula-input'; + SheetDataValidationModel, +} from '@univerjs/sheets-data-validation'; +import { describe, expect, it } from 'vitest'; +import { DateShowTimeOption } from '../../components/DateShowTimeOption'; import { ListRenderModeInput } from '../../components/ListRenderModeInput'; class TestPath2D { diff --git a/packages/sheets-data-validation-ui/src/views/validator-views/checkbox-validator-view.ts b/packages/sheets-data-validation-ui/src/views/validator-views/checkbox-validator-view.ts index a9e4a83ebd3e..81951b7c47ee 100644 --- a/packages/sheets-data-validation-ui/src/views/validator-views/checkbox-validator-view.ts +++ b/packages/sheets-data-validation-ui/src/views/validator-views/checkbox-validator-view.ts @@ -15,7 +15,7 @@ */ import { DataValidationType } from '@univerjs/core'; -import { CHECKBOX_FORMULA_INPUT_NAME } from '../components/formula-input'; +import { CHECKBOX_FORMULA_INPUT_NAME } from '@univerjs/sheets-data-validation'; import { CheckboxRender } from '../widgets/checkbox-widget'; import { BaseSheetDataValidatorView } from './sheet-validator-view'; diff --git a/packages/sheets-data-validation-ui/src/views/validator-views/custom-validator-view.ts b/packages/sheets-data-validation-ui/src/views/validator-views/custom-validator-view.ts index 7c9be973e96a..bc2c4d2fdb51 100644 --- a/packages/sheets-data-validation-ui/src/views/validator-views/custom-validator-view.ts +++ b/packages/sheets-data-validation-ui/src/views/validator-views/custom-validator-view.ts @@ -15,7 +15,7 @@ */ import { DataValidationType } from '@univerjs/core'; -import { CUSTOM_FORMULA_INPUT_NAME } from '../components/formula-input'; +import { CUSTOM_FORMULA_INPUT_NAME } from '@univerjs/sheets-data-validation'; import { BaseSheetDataValidatorView } from './sheet-validator-view'; export class CustomFormulaValidatorView extends BaseSheetDataValidatorView { diff --git a/packages/sheets-data-validation-ui/src/views/validator-views/date-validator-view.ts b/packages/sheets-data-validation-ui/src/views/validator-views/date-validator-view.ts index 6f393c808658..5cebf9f160d6 100644 --- a/packages/sheets-data-validation-ui/src/views/validator-views/date-validator-view.ts +++ b/packages/sheets-data-validation-ui/src/views/validator-views/date-validator-view.ts @@ -16,8 +16,8 @@ import { DataValidationType } from '@univerjs/core'; import { DataValidatorDropdownType } from '@univerjs/data-validation'; +import { BASE_FORMULA_INPUT_NAME } from '@univerjs/sheets-data-validation'; import { DateShowTimeOption } from '../components/DateShowTimeOption'; -import { BASE_FORMULA_INPUT_NAME } from '../components/formula-input'; import { BaseSheetDataValidatorView } from './sheet-validator-view'; export class DateValidatorView extends BaseSheetDataValidatorView { diff --git a/packages/sheets-data-validation-ui/src/views/validator-views/decimal-validator-view.ts b/packages/sheets-data-validation-ui/src/views/validator-views/decimal-validator-view.ts index 306ffc837a32..73c68d424fb8 100644 --- a/packages/sheets-data-validation-ui/src/views/validator-views/decimal-validator-view.ts +++ b/packages/sheets-data-validation-ui/src/views/validator-views/decimal-validator-view.ts @@ -15,7 +15,7 @@ */ import { DataValidationType } from '@univerjs/core'; -import { BASE_FORMULA_INPUT_NAME } from '../components/formula-input'; +import { BASE_FORMULA_INPUT_NAME } from '@univerjs/sheets-data-validation'; import { BaseSheetDataValidatorView } from './sheet-validator-view'; export class DecimalValidatorView extends BaseSheetDataValidatorView { diff --git a/packages/sheets-data-validation-ui/src/views/validator-views/list-validator-view.ts b/packages/sheets-data-validation-ui/src/views/validator-views/list-validator-view.ts index c37425aa02d6..eeac193adaf6 100644 --- a/packages/sheets-data-validation-ui/src/views/validator-views/list-validator-view.ts +++ b/packages/sheets-data-validation-ui/src/views/validator-views/list-validator-view.ts @@ -18,7 +18,7 @@ import type { Nullable } from '@univerjs/core'; import type { IBaseDataValidationWidget } from '@univerjs/data-validation'; import { DataValidationType } from '@univerjs/core'; import { DataValidatorDropdownType } from '@univerjs/data-validation'; -import { LIST_FORMULA_INPUT_NAME } from '../components/formula-input'; +import { LIST_FORMULA_INPUT_NAME } from '@univerjs/sheets-data-validation'; import { ListRenderModeInput } from '../components/ListRenderModeInput'; import { DropdownWidget } from '../widgets/dropdown-widget'; import { BaseSheetDataValidatorView } from './sheet-validator-view'; diff --git a/packages/sheets-data-validation-ui/src/views/validator-views/sheet-validator-view.ts b/packages/sheets-data-validation-ui/src/views/validator-views/sheet-validator-view.ts index b36f206f3e12..1ee228b507f9 100644 --- a/packages/sheets-data-validation-ui/src/views/validator-views/sheet-validator-view.ts +++ b/packages/sheets-data-validation-ui/src/views/validator-views/sheet-validator-view.ts @@ -17,7 +17,7 @@ import type { Nullable } from '@univerjs/core'; import type { DataValidatorDropdownType, IBaseDataValidationWidget } from '@univerjs/data-validation'; import { Inject, Injector } from '@univerjs/core'; -import { LIST_FORMULA_INPUT_NAME } from '../components/formula-input'; +import { LIST_FORMULA_INPUT_NAME } from '@univerjs/sheets-data-validation'; /** * This is the base class for all sheet data validator views. It is used to extend {@link BaseDataValidator}. diff --git a/packages/sheets-data-validation-ui/src/views/validator-views/text-length-validator.view.ts b/packages/sheets-data-validation-ui/src/views/validator-views/text-length-validator.view.ts index ee80167d94ef..2fcabd2af4d1 100644 --- a/packages/sheets-data-validation-ui/src/views/validator-views/text-length-validator.view.ts +++ b/packages/sheets-data-validation-ui/src/views/validator-views/text-length-validator.view.ts @@ -15,7 +15,7 @@ */ import { DataValidationType } from '@univerjs/core'; -import { BASE_FORMULA_INPUT_NAME } from '../components/formula-input'; +import { BASE_FORMULA_INPUT_NAME } from '@univerjs/sheets-data-validation'; import { BaseSheetDataValidatorView } from './sheet-validator-view'; export class TextLengthValidatorView extends BaseSheetDataValidatorView { diff --git a/packages/sheets-data-validation-ui/src/views/validator-views/whole-validator-view.ts b/packages/sheets-data-validation-ui/src/views/validator-views/whole-validator-view.ts index 15a08757b53b..70c42c65221c 100644 --- a/packages/sheets-data-validation-ui/src/views/validator-views/whole-validator-view.ts +++ b/packages/sheets-data-validation-ui/src/views/validator-views/whole-validator-view.ts @@ -15,7 +15,7 @@ */ import { DataValidationType } from '@univerjs/core'; -import { BASE_FORMULA_INPUT_NAME } from '../components/formula-input'; +import { BASE_FORMULA_INPUT_NAME } from '@univerjs/sheets-data-validation'; import { BaseSheetDataValidatorView } from './sheet-validator-view'; export class WholeValidatorView extends BaseSheetDataValidatorView { diff --git a/packages/sheets-drawing-ui/src/controllers/sheet-drawing-transform-affected.controller.ts b/packages/sheets-drawing-ui/src/controllers/sheet-drawing-transform-affected.controller.ts index 303d9a3b47ed..09463ee61a7b 100644 --- a/packages/sheets-drawing-ui/src/controllers/sheet-drawing-transform-affected.controller.ts +++ b/packages/sheets-drawing-ui/src/controllers/sheet-drawing-transform-affected.controller.ts @@ -1493,6 +1493,10 @@ export class SheetDrawingTransformAffectedController extends Disposable implemen const removeDrawings: IDrawingParam[] = []; Object.keys(drawingMap ?? {}).forEach((unitId) => { + if (unitId !== showUnitId) { + return; + } + const subUnitMap = drawingMap[unitId] ?? {}; Object.keys(subUnitMap).forEach((subUnitId) => { diff --git a/packages/sheets-drawing-ui/src/embed/floating-host/__tests__/register-sheets-drawing-floating-host.spec.ts b/packages/sheets-drawing-ui/src/embed/floating-host/__tests__/register-sheets-drawing-floating-host.spec.ts new file mode 100644 index 000000000000..d31ea3f03493 --- /dev/null +++ b/packages/sheets-drawing-ui/src/embed/floating-host/__tests__/register-sheets-drawing-floating-host.spec.ts @@ -0,0 +1,136 @@ +/** + * 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 { LifecycleService, LifecycleStages } from '@univerjs/core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { SheetCanvasFloatDomManagerService } from '../../../services/canvas-float-dom-manager.service'; +import { registerSheetsDrawingFloatingHostCapability, SHEETS_DRAWING_FLOATING_HOST_DEPENDENCIES, touchSheetsDrawingFloatingHostCapabilityWhenReady } from '../register-sheets-drawing-floating-host'; + +const registerDependencies = vi.hoisted(() => vi.fn()); +const touchDependencies = vi.hoisted(() => vi.fn()); + +vi.mock('@univerjs/core', async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + registerDependencies, + touchDependencies, + }; +}); + +describe('registerSheetsDrawingFloatingHostCapability', () => { + beforeEach(() => { + registerDependencies.mockClear(); + touchDependencies.mockClear(); + }); + + it('registers the minimal sheet float DOM host dependency', () => { + const injector = { + has: () => false, + }; + + registerSheetsDrawingFloatingHostCapability(injector as never); + + expect(SHEETS_DRAWING_FLOATING_HOST_DEPENDENCIES).toEqual([ + [SheetCanvasFloatDomManagerService], + ]); + expect(registerDependencies).toHaveBeenCalledWith(injector, [ + [SheetCanvasFloatDomManagerService], + ]); + expect(touchDependencies).toHaveBeenCalledWith(injector, [ + [SheetCanvasFloatDomManagerService], + ]); + }); + + it('does not register the floating host dependency twice', () => { + const injector = { + has: (token: unknown) => token === SheetCanvasFloatDomManagerService, + }; + + registerSheetsDrawingFloatingHostCapability(injector as never); + + expect(registerDependencies).not.toHaveBeenCalled(); + expect(touchDependencies).toHaveBeenCalledWith(injector, [ + [SheetCanvasFloatDomManagerService], + ]); + }); + + it('defers touching the floating host until lifecycle ready', async () => { + let resolveReady: () => void = () => {}; + const onStage = vi.fn(() => new Promise((resolve) => { + resolveReady = resolve; + })); + const lifecycleService = { + stage: LifecycleStages.Starting, + onStage, + }; + const injector = { + has: (token: unknown) => token === LifecycleService, + get: (token: unknown) => { + if (token === LifecycleService) { + return lifecycleService; + } + throw new Error('Unexpected dependency'); + }, + }; + + registerSheetsDrawingFloatingHostCapability(injector as never); + + expect(onStage).toHaveBeenCalledWith(LifecycleStages.Ready); + expect(touchDependencies).not.toHaveBeenCalled(); + + resolveReady(); + await Promise.resolve(); + + expect(touchDependencies).toHaveBeenCalledWith(injector, [ + [SheetCanvasFloatDomManagerService], + ]); + }); + + it('allows the full sheets drawing UI plugin to touch an already registered floating host', async () => { + let resolveReady: () => void = () => {}; + const onStage = vi.fn(() => new Promise((resolve) => { + resolveReady = resolve; + })); + const lifecycleService = { + stage: LifecycleStages.Starting, + onStage, + }; + const injector = { + has: (token: unknown) => token === LifecycleService || token === SheetCanvasFloatDomManagerService, + get: (token: unknown) => { + if (token === LifecycleService) { + return lifecycleService; + } + throw new Error('Unexpected dependency'); + }, + }; + + touchSheetsDrawingFloatingHostCapabilityWhenReady(injector as never); + + expect(registerDependencies).not.toHaveBeenCalled(); + expect(onStage).toHaveBeenCalledWith(LifecycleStages.Ready); + expect(touchDependencies).not.toHaveBeenCalled(); + + resolveReady(); + await Promise.resolve(); + + expect(touchDependencies).toHaveBeenCalledWith(injector, [ + [SheetCanvasFloatDomManagerService], + ]); + }); +}); diff --git a/packages/sheets-drawing-ui/src/embed/floating-host/index.ts b/packages/sheets-drawing-ui/src/embed/floating-host/index.ts new file mode 100644 index 000000000000..faf2c1f1c5b5 --- /dev/null +++ b/packages/sheets-drawing-ui/src/embed/floating-host/index.ts @@ -0,0 +1,21 @@ +/** + * 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. + */ + +export { + registerSheetsDrawingFloatingHostCapability, + SHEETS_DRAWING_FLOATING_HOST_DEPENDENCIES, + touchSheetsDrawingFloatingHostCapabilityWhenReady, +} from './register-sheets-drawing-floating-host'; diff --git a/packages/sheets-drawing-ui/src/embed/floating-host/register-sheets-drawing-floating-host.ts b/packages/sheets-drawing-ui/src/embed/floating-host/register-sheets-drawing-floating-host.ts new file mode 100644 index 000000000000..1961766933eb --- /dev/null +++ b/packages/sheets-drawing-ui/src/embed/floating-host/register-sheets-drawing-floating-host.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 type { Dependency, Injector } from '@univerjs/core'; +import { LifecycleService, LifecycleStages, registerDependencies, touchDependencies } from '@univerjs/core'; +import { SheetCanvasFloatDomManagerService } from '../../services/canvas-float-dom-manager.service'; + +export const SHEETS_DRAWING_FLOATING_HOST_DEPENDENCIES: Dependency[] = [ + [SheetCanvasFloatDomManagerService], +]; + +export function registerSheetsDrawingFloatingHostCapability(injector: Injector): void { + if (!injector.has(SheetCanvasFloatDomManagerService)) { + registerDependencies(injector, SHEETS_DRAWING_FLOATING_HOST_DEPENDENCIES); + } + touchSheetsDrawingFloatingHostCapabilityWhenReady(injector); +} + +function touchSheetsDrawingFloatingHostCapability(injector: Injector): void { + touchDependencies(injector, [[SheetCanvasFloatDomManagerService]]); +} + +export function touchSheetsDrawingFloatingHostCapabilityWhenReady(injector: Injector): void { + if (!injector.has(LifecycleService)) { + touchSheetsDrawingFloatingHostCapability(injector); + return; + } + + const lifecycleService = injector.get(LifecycleService); + if (lifecycleService.stage >= LifecycleStages.Ready) { + touchSheetsDrawingFloatingHostCapability(injector); + return; + } + + void lifecycleService.onStage(LifecycleStages.Ready).then(() => { + touchSheetsDrawingFloatingHostCapability(injector); + }); +} diff --git a/packages/sheets-ui/src/menu/mobile-menu.ts b/packages/sheets-drawing-ui/src/embed/index.ts similarity index 94% rename from packages/sheets-ui/src/menu/mobile-menu.ts rename to packages/sheets-drawing-ui/src/embed/index.ts index f8cdcb0eb539..a1166a7b95a6 100644 --- a/packages/sheets-ui/src/menu/mobile-menu.ts +++ b/packages/sheets-drawing-ui/src/embed/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export { menuSchema } from './schema'; +export * from './floating-host'; diff --git a/packages/sheets-drawing-ui/src/index.ts b/packages/sheets-drawing-ui/src/index.ts index 36031036edd4..a63d5123b941 100644 --- a/packages/sheets-drawing-ui/src/index.ts +++ b/packages/sheets-drawing-ui/src/index.ts @@ -28,6 +28,7 @@ export { SidebarSheetDrawingOperation } from './commands/operations/open-drawing export type { IUniverSheetsDrawingUIConfig } from './config/config'; export { SheetsDrawingGroupCopyPasteController } from './controllers/sheet-drawing-group-copy-paste.controller'; export { SheetDrawingUpdateController } from './controllers/sheet-drawing-update.controller'; +export { registerSheetsDrawingFloatingHostCapability, SHEETS_DRAWING_FLOATING_HOST_DEPENDENCIES } from './embed'; export { SHEETS_IMAGE_MENU_ID } from './menu/image.menu'; export { UniverSheetsDrawingUIPlugin } from './plugin'; export { BatchSaveImagesService, FileNamePart, IBatchSaveImagesService } from './services/batch-save-images.service'; diff --git a/packages/sheets-drawing-ui/src/menu/drawing-popup-menu.controller.ts b/packages/sheets-drawing-ui/src/menu/drawing-popup-menu.controller.ts index 7bbbc0473672..f0940473a999 100644 --- a/packages/sheets-drawing-ui/src/menu/drawing-popup-menu.controller.ts +++ b/packages/sheets-drawing-ui/src/menu/drawing-popup-menu.controller.ts @@ -163,8 +163,8 @@ export class DrawingPopupMenuController extends RxDisposable { const { unitId, subUnitId, drawingId, drawingType } = drawingParam; // drawingParam should be ICanvasFloatDom, use for disable popup dialog - const data = (drawingParam as ISheetFloatDom).data as Record; - if (data && data.disablePopup) { + const data = (drawingParam as ISheetFloatDom).data as Record | undefined; + if (data && (data.disablePopup || (data.version === 1 && typeof data.embedId === 'string'))) { return; } diff --git a/packages/sheets-drawing-ui/src/plugin.ts b/packages/sheets-drawing-ui/src/plugin.ts index f3e713d822c7..40d355598ff0 100644 --- a/packages/sheets-drawing-ui/src/plugin.ts +++ b/packages/sheets-drawing-ui/src/plugin.ts @@ -48,6 +48,7 @@ import { SheetDrawingPrintingController } from './controllers/sheet-drawing-prin import { SheetDrawingTransformAffectedController } from './controllers/sheet-drawing-transform-affected.controller'; import { SheetDrawingUpdateController } from './controllers/sheet-drawing-update.controller'; import { SheetDrawingUIController } from './controllers/ui.controller'; +import { touchSheetsDrawingFloatingHostCapabilityWhenReady } from './embed/floating-host'; import { DrawingPopupMenuController } from './menu/drawing-popup-menu.controller'; import { BatchSaveImagesService, IBatchSaveImagesService } from './services/batch-save-images.service'; import { SheetCanvasFloatDomManagerService } from './services/canvas-float-dom-manager.service'; @@ -100,9 +101,7 @@ export class UniverSheetsDrawingUIPlugin extends Plugin { [DrawingContextMenuController], ]); - touchDependencies(this._injector, [ - [SheetCanvasFloatDomManagerService], - ]); + touchSheetsDrawingFloatingHostCapabilityWhenReady(this._injector); this._injector.get(ComponentsController); } diff --git a/packages/sheets-drawing-ui/src/services/__tests__/canvas-float-dom-manager.service.spec.ts b/packages/sheets-drawing-ui/src/services/__tests__/canvas-float-dom-manager.service.spec.ts index 2877aa99b63b..37e799257b12 100644 --- a/packages/sheets-drawing-ui/src/services/__tests__/canvas-float-dom-manager.service.spec.ts +++ b/packages/sheets-drawing-ui/src/services/__tests__/canvas-float-dom-manager.service.spec.ts @@ -30,6 +30,7 @@ import { LocaleType, UniverInstanceType, } from '@univerjs/core'; +import { getDrawingShapeKeyByDrawingSearch } from '@univerjs/drawing'; import { IRenderManagerService, Rect, SHEET_VIEWPORT_KEY, SpreadsheetSkeleton } from '@univerjs/engine-render'; import { DrawingApplyType, InsertSheetDrawingCommand, ISheetDrawingService, RemoveSheetDrawingCommand, SetDrawingApplyMutation, SetSheetDrawingCommand } from '@univerjs/sheets-drawing'; import { ISheetSelectionRenderService, SheetSkeletonManagerService } from '@univerjs/sheets-ui'; @@ -38,8 +39,24 @@ import { BehaviorSubject, Subject } from 'rxjs'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { createSheetsDrawingUiTestBed } from '../../__tests__/create-sheets-drawing-ui-test-bed'; import { + applyFloatDomTransformerConfig, calcSheetFloatDomPosition, + createFloatDomHostClickIntent, + createFloatDomMoveDragState, + isCanvasFloatDomDrawingType, + resolveFloatDomMoveDragTransform, SheetCanvasFloatDomManagerService, + shouldActivateStage2FromHostClickIntent, + shouldActivateStage2FromHostPointer, + shouldAutoMountFloatDomRuntime, + shouldForwardSheetHostedEmbedFloatDomEvent, + shouldPassThroughFloatDomActivationEvent, + shouldPassThroughFloatDomRuntimeEvents, + shouldPreserveFloatDomOnFocusChange, + shouldStartFloatDomMoveFromHandle, + shouldUpdateFloatDomLayerOnRuntimeStageChange, + shouldUseFloatDomPreviewObject, + syncFloatDomHostSelectionOnStageEnter, transformBound2DOMBound, } from '../canvas-float-dom-manager.service'; @@ -66,6 +83,26 @@ const BASE_WORKBOOK_DATA: IWorkbookData = { }, }; +const TWO_SHEET_WORKBOOK_DATA: IWorkbookData = { + ...BASE_WORKBOOK_DATA, + sheetOrder: ['sheet1', 'sheet2'], + sheets: { + ...BASE_WORKBOOK_DATA.sheets, + sheet2: { + id: 'sheet2', + name: 'Sheet2', + rowCount: 20, + columnCount: 20, + defaultColumnWidth: 72, + defaultRowHeight: 24, + rowHeader: { width: 46 }, + columnHeader: { height: 28 }, + cellData: {}, + hidden: BooleanNumber.FALSE, + }, + }, +}; + function createWorkbookDataWithFreeze(): IWorkbookData { return { ...BASE_WORKBOOK_DATA, @@ -332,9 +369,622 @@ function expectLayout(layout: IFloatDomLayout, expected: IFloatDomLayout): void expect(layout).toEqual(expected); } +function createService(drawing: unknown) { + const dispose = vi.fn(); + const removeObject = vi.fn(); + const disposeRenderObject = vi.fn(); + const transformer = { + clearControlByIds: vi.fn(), + clearSelectedObjects: vi.fn(), + }; + const renderObject = { + id: 'rect-1', + oKey: 'rect-1', + dispose: disposeRenderObject, + }; + const syncExecuteCommand = vi.fn(() => true); + const getDrawingByParam = vi.fn(() => drawing); + const getBatchRemoveOp = vi.fn(() => ({ + unitId: 'unit-1', + subUnitId: 'sheet-1', + redo: ['redo-op'], + objects: ['object-1'], + })); + const service = Object.create(SheetCanvasFloatDomManagerService.prototype) as any; + + service._domLayerInfoMap = new Map([ + ['float-dom-1', { + unitId: 'unit-1', + subUnitId: 'sheet-1', + dispose: { dispose }, + rect: renderObject, + }], + ]); + service._drawingManagerService = { getDrawingByParam }; + service._commandService = { syncExecuteCommand }; + service._sheetDrawingService = { getBatchRemoveOp }; + service._getSceneAndTransformerByDrawingSearch = vi.fn(() => ({ + scene: { + getObjectIncludeInGroup: vi.fn(() => renderObject), + getTransformer: vi.fn(() => transformer), + removeObject, + }, + transformer, + })); + + return { service, dispose, disposeRenderObject, removeObject, syncExecuteCommand, getDrawingByParam, getBatchRemoveOp, transformer }; +} + describe('SheetCanvasFloatDomManagerService', () => { const disposables: Array> = []; + it('treats embed block drawings as canvas float dom drawings', () => { + expect(isCanvasFloatDomDrawingType(DrawingTypeEnum.DRAWING_BLOCK)).toBe(true); + expect(isCanvasFloatDomDrawingType(DrawingTypeEnum.DRAWING_DOM)).toBe(true); + expect(isCanvasFloatDomDrawingType(DrawingTypeEnum.DRAWING_CHART)).toBe(true); + expect(isCanvasFloatDomDrawingType(DrawingTypeEnum.DRAWING_IMAGE)).toBe(false); + }); + + it('only defers auto mounting for same-sheet embed float doms that opt into stage2 runtime mounting', () => { + expect(shouldAutoMountFloatDomRuntime({ + data: { + version: 1, + embedId: 'embed-sheet', + hostType: UniverInstanceType.UNIVER_SHEET, + childType: UniverInstanceType.UNIVER_SHEET, + runtimeMountMode: 'stage2', + }, + } as any)).toBe(false); + expect(shouldAutoMountFloatDomRuntime({ + data: { + version: 1, + embedId: 'embed-doc', + hostType: UniverInstanceType.UNIVER_SHEET, + childType: UniverInstanceType.UNIVER_DOC, + runtimeMountMode: 'stage2', + }, + } as any)).toBe(true); + }); + + it('keeps existing float doms auto mounted by default', () => { + expect(shouldAutoMountFloatDomRuntime({ + data: { + version: 1, + embedId: 'embed-doc', + }, + } as any)).toBe(true); + expect(shouldAutoMountFloatDomRuntime({ + data: { + runtimeMountMode: 'stage2', + }, + } as any)).toBe(true); + }); + + it('keeps embed float dom layers rendered when focus moves into child units', () => { + expect(shouldPreserveFloatDomOnFocusChange({ + data: { + version: 1, + embedId: 'embed-doc', + hostType: UniverInstanceType.UNIVER_SHEET, + childType: UniverInstanceType.UNIVER_DOC, + runtimeMountMode: 'stage2', + }, + } as any)).toBe(true); + expect(shouldPreserveFloatDomOnFocusChange({ + data: { label: 'plain sheet float dom' }, + } as any)).toBe(false); + }); + + it('uses host scene preview image objects only for same-sheet stage2-only embed float doms', () => { + expect(shouldUseFloatDomPreviewObject({ + data: { + version: 1, + embedId: 'embed-sheet', + hostType: UniverInstanceType.UNIVER_SHEET, + childType: UniverInstanceType.UNIVER_SHEET, + runtimeMountMode: 'stage2', + }, + } as any)).toBe(true); + expect(shouldUseFloatDomPreviewObject({ + data: { + version: 1, + embedId: 'embed-doc', + hostType: UniverInstanceType.UNIVER_SHEET, + childType: UniverInstanceType.UNIVER_DOC, + runtimeMountMode: 'stage2', + }, + } as any)).toBe(false); + expect(shouldUseFloatDomPreviewObject({ + data: { + version: 1, + embedId: 'embed-legacy', + runtimeMountMode: 'stage2', + }, + } as any)).toBe(false); + }); + + it('allows host event pass-through before stage2 so sheet-hosted embed blocks can be selected', () => { + const floatDomParam = { + data: { + version: 1, + embedId: 'embed-base', + hostType: UniverInstanceType.UNIVER_SHEET, + childType: UniverInstanceType.UNIVER_BASE, + runtimeMountMode: 'stage2', + }, + }; + + expect(shouldPassThroughFloatDomRuntimeEvents(floatDomParam as any, 'inactive')).toBe(true); + expect(shouldPassThroughFloatDomRuntimeEvents(floatDomParam as any, 'stage1')).toBe(true); + }); + + it('disables host event pass-through for every sheet-hosted stage2 embed runtime', () => { + expect(shouldPassThroughFloatDomRuntimeEvents({ + data: { + version: 1, + embedId: 'embed-slide', + hostType: UniverInstanceType.UNIVER_SHEET, + childType: UniverInstanceType.UNIVER_SHEET, + runtimeMountMode: 'stage2', + }, + } as any, 'stage2')).toBe(false); + expect(shouldPassThroughFloatDomRuntimeEvents({ + data: { + version: 1, + embedId: 'embed-doc', + hostType: UniverInstanceType.UNIVER_SHEET, + childType: UniverInstanceType.UNIVER_DOC, + runtimeMountMode: 'stage2', + }, + } as any, 'stage2')).toBe(false); + expect(shouldPassThroughFloatDomRuntimeEvents({ + data: { + version: 1, + embedId: 'embed-base', + hostType: UniverInstanceType.UNIVER_SHEET, + childType: UniverInstanceType.UNIVER_BASE, + runtimeMountMode: 'stage2', + }, + } as any, 'stage2')).toBe(false); + expect(shouldPassThroughFloatDomRuntimeEvents({ + data: { + version: 1, + embedId: 'embed-slide', + hostType: UniverInstanceType.UNIVER_SHEET, + childType: UniverInstanceType.UNIVER_SLIDE, + runtimeMountMode: 'stage2', + }, + } as any, 'stage2')).toBe(false); + expect(shouldPassThroughFloatDomRuntimeEvents({ + data: { + label: 'plain sheet float dom', + }, + } as any, 'stage2')).toBe(true); + }); + + it('keeps auto-mounted sheet embed runtimes stable across stage changes', () => { + expect(shouldUpdateFloatDomLayerOnRuntimeStageChange({ + data: { + version: 1, + embedId: 'embed-slide', + hostType: UniverInstanceType.UNIVER_SHEET, + childType: UniverInstanceType.UNIVER_SLIDE, + runtimeMountMode: 'auto', + }, + } as any)).toBe(false); + expect(shouldUpdateFloatDomLayerOnRuntimeStageChange({ + data: { + version: 1, + embedId: 'embed-doc', + hostType: UniverInstanceType.UNIVER_SHEET, + childType: UniverInstanceType.UNIVER_DOC, + runtimeMountMode: 'auto', + }, + } as any)).toBe(false); + expect(shouldUpdateFloatDomLayerOnRuntimeStageChange({ + data: { + version: 1, + embedId: 'embed-sheet', + hostType: UniverInstanceType.UNIVER_SHEET, + childType: UniverInstanceType.UNIVER_SHEET, + runtimeMountMode: 'stage2', + }, + } as any)).toBe(true); + }); + + it('blocks sheet-host event forwarding from active stage2 embed runtimes', () => { + const runtime = document.createElement('div'); + runtime.dataset.embedFloatDom = 'true'; + runtime.dataset.embedFloatStage = 'stage2'; + const target = document.createElement('button'); + runtime.appendChild(target); + + expect(shouldForwardSheetHostedEmbedFloatDomEvent({ + data: { + version: 1, + embedId: 'embed-slide', + hostType: UniverInstanceType.UNIVER_SHEET, + childType: UniverInstanceType.UNIVER_SLIDE, + }, + } as any, { target } as any)).toBe(false); + + runtime.dataset.embedFloatStage = 'stage1'; + expect(shouldForwardSheetHostedEmbedFloatDomEvent({ + data: { + version: 1, + embedId: 'embed-slide', + hostType: UniverInstanceType.UNIVER_SHEET, + childType: UniverInstanceType.UNIVER_SLIDE, + }, + } as any, { target } as any)).toBe(true); + }); + + it('applies embed transformer config with visual padding and aspect-ratio locking', () => { + const rect = {}; + + applyFloatDomTransformerConfig(rect as any, { + data: { + version: 1, + embedId: 'embed-slide', + resizeBehavior: 'aspect-ratio', + }, + } as any); + + expect((rect as any).transformerConfig).toEqual(expect.objectContaining({ + borderEnabled: true, + borderSpacing: 2, + keepRatio: true, + rotateEnabled: false, + resizeEnabled: true, + })); + }); + + it('mounts and unmounts lazy float dom runtime from stored runtime config', () => { + const addFloatDom = vi.fn(); + const removeFloatDom = vi.fn(); + const updateFloatDom = vi.fn(); + const service = Object.create(SheetCanvasFloatDomManagerService.prototype) as any; + const floatDomConfig = { + id: 'float-dom-1', + componentKey: 'Component', + unitId: 'unit-1', + data: { + version: 1, + embedId: 'embed-slide', + hostType: UniverInstanceType.UNIVER_SHEET, + childType: UniverInstanceType.UNIVER_SHEET, + runtimeMountMode: 'stage2', + }, + }; + service._canvasFloatDomService = { + addFloatDom, + removeFloatDom, + updateFloatDom, + domLayers: [['float-dom-1', floatDomConfig]], + }; + service._domLayerInfoMap = new Map([ + ['float-dom-1', { + id: 'float-dom-1', + unitId: 'unit-1', + subUnitId: 'sheet-1', + floatDomConfig, + runtimeMounted: false, + }], + ]); + + expect(service.isFloatDomRuntimeMounted('float-dom-1')).toBe(false); + expect(service.mountFloatDomRuntime('float-dom-1')).toBe(true); + expect(service.isFloatDomRuntimeMounted('float-dom-1')).toBe(true); + expect(addFloatDom).not.toHaveBeenCalled(); + expect(updateFloatDom).toHaveBeenCalledWith('float-dom-1', expect.objectContaining({ + eventPassThrough: false, + props: expect.objectContaining({ initialStage: 'stage2' }), + })); + + expect(service.mountFloatDomRuntime('float-dom-1')).toBe(true); + expect(updateFloatDom).toHaveBeenCalledTimes(1); + + service.unmountFloatDomRuntime('float-dom-1'); + expect(removeFloatDom).not.toHaveBeenCalled(); + expect(updateFloatDom).toHaveBeenLastCalledWith('float-dom-1', { + eventPassThrough: true, + props: undefined, + }); + expect(service.isFloatDomRuntimeMounted('float-dom-1')).toBe(false); + }); + + it('promotes lazy float dom runtime from inactive to stage2 on the second activation', () => { + const addFloatDom = vi.fn(); + const updateFloatDom = vi.fn(); + const service = Object.create(SheetCanvasFloatDomManagerService.prototype) as any; + const floatDomConfig = { + id: 'float-dom-1', + componentKey: 'Component', + unitId: 'unit-1', + data: { + version: 1, + embedId: 'embed-slide', + runtimeMountMode: 'stage2', + }, + }; + service._canvasFloatDomService = { + addFloatDom, + updateFloatDom, + removeFloatDom: vi.fn(), + domLayers: [['float-dom-1', floatDomConfig]], + }; + service._domLayerInfoMap = new Map([ + ['float-dom-1', { + id: 'float-dom-1', + unitId: 'unit-1', + subUnitId: 'sheet-1', + floatDomConfig, + runtimeMounted: false, + }], + ]); + + expect(service.promoteFloatDomRuntimeStage('float-dom-1')).toBe('stage1'); + expect(addFloatDom).not.toHaveBeenCalled(); + expect(service.promoteFloatDomRuntimeStage('float-dom-1')).toBe('stage2'); + expect(addFloatDom).not.toHaveBeenCalled(); + expect(updateFloatDom).toHaveBeenCalledWith('float-dom-1', expect.objectContaining({ + eventPassThrough: false, + props: expect.objectContaining({ initialStage: 'stage2' }), + })); + }); + + it('keeps auto-mounted sheet-hosted embed runtime stage changes inside the runtime layer', () => { + const updateFloatDom = vi.fn(); + const service = Object.create(SheetCanvasFloatDomManagerService.prototype) as any; + const floatDomConfig = { + id: 'float-dom-1', + componentKey: 'Component', + unitId: 'unit-1', + data: { + version: 1, + embedId: 'embed-base', + hostType: UniverInstanceType.UNIVER_SHEET, + childType: UniverInstanceType.UNIVER_BASE, + runtimeMountMode: 'stage2', + }, + }; + service._canvasFloatDomService = { + updateFloatDom, + }; + service._domLayerInfoMap = new Map([ + ['float-dom-1', { + id: 'float-dom-1', + unitId: 'unit-1', + subUnitId: 'sheet-1', + floatDomConfig, + runtimeMounted: true, + runtimeStage: 'inactive', + }], + ]); + + expect(service.promoteFloatDomRuntimeStage('float-dom-1')).toBe('stage1'); + expect(updateFloatDom).not.toHaveBeenCalled(); + + expect(service.promoteFloatDomRuntimeStage('float-dom-1')).toBe('stage2'); + expect(updateFloatDom).not.toHaveBeenCalled(); + }); + + it('lets stage1 activation continue to host selection but stops stage2 activation', () => { + expect(shouldPassThroughFloatDomActivationEvent('stage1')).toBe(true); + expect(shouldPassThroughFloatDomActivationEvent('stage2')).toBe(false); + expect(shouldPassThroughFloatDomActivationEvent(undefined)).toBe(true); + }); + + it('syncs host transformer when a lazy float dom enters stage1 or stage2', () => { + const attachTransformerTo = vi.fn(); + const clearControlByIds = vi.fn(); + const clearSelectedObjects = vi.fn(); + const transformer = { + cleared: false, + clearSelectedObjects() { + this.cleared = true; + clearSelectedObjects(); + }, + }; + const renderObject = { + transformer: { clearControlByIds }, + scene: { + attachTransformerTo, + getTransformer: () => transformer, + }, + }; + const rect = { oKey: 'rect-key-1' }; + + syncFloatDomHostSelectionOnStageEnter('stage1', renderObject as any, rect as any); + expect(attachTransformerTo).toHaveBeenCalledWith(rect); + + syncFloatDomHostSelectionOnStageEnter('stage2', renderObject as any, rect as any); + expect(clearControlByIds).toHaveBeenCalledWith(['rect-key-1']); + expect(clearSelectedObjects).toHaveBeenCalled(); + expect(transformer.cleared).toBe(true); + }); + + it('allows host-level block body clicks to activate stage2 after stage1 selection', () => { + const info = { + runtimeMounted: false, + runtimeStage: 'stage1', + position$: { + getValue: () => ({ + startX: 100, + endX: 420, + startY: 80, + endY: 260, + }), + }, + rect: { + left: 1000, + top: 800, + width: 320, + height: 180, + }, + }; + + expect(shouldActivateStage2FromHostPointer(info as any, { offsetX: 120, offsetY: 100 } as any)).toBe(true); + expect(shouldActivateStage2FromHostPointer(info as any, { offsetX: 20, offsetY: 100 } as any)).toBe(false); + expect(shouldActivateStage2FromHostPointer({ ...info, runtimeStage: 'inactive' } as any, { offsetX: 120, offsetY: 100 } as any)).toBe(false); + }); + + it('activates stage2 from host only on pointerup click intent', () => { + const info = createStage1FloatDomInfo(); + const intent = createFloatDomHostClickIntent(info as any, { + type: 'pointerdown', + pointerId: 1, + offsetX: 120, + offsetY: 100, + } as any); + + expect(intent).toEqual(expect.objectContaining({ + pointerId: 1, + startOffsetX: 120, + startOffsetY: 100, + })); + expect(shouldActivateStage2FromHostClickIntent(info as any, intent, { + type: 'pointerdown', + pointerId: 1, + offsetX: 120, + offsetY: 100, + } as any)).toBe(false); + expect(shouldActivateStage2FromHostClickIntent(info as any, intent, { + type: 'pointerup', + pointerId: 1, + offsetX: 121, + offsetY: 101, + } as any)).toBe(true); + }); + + it('does not activate stage2 from host after drag intent', () => { + const info = createStage1FloatDomInfo(); + const intent = createFloatDomHostClickIntent(info as any, { + type: 'pointerdown', + pointerId: 1, + offsetX: 120, + offsetY: 100, + } as any); + + expect(shouldActivateStage2FromHostClickIntent(info as any, intent, { + type: 'pointerup', + pointerId: 1, + offsetX: 140, + offsetY: 100, + } as any)).toBe(false); + }); + + it('starts host float dom moving only from the matching drag handle event', () => { + const info = { + id: 'anchor-1', + unitId: 'host-1', + rect: { + left: 100, + top: 40, + }, + }; + + expect(shouldStartFloatDomMoveFromHandle(info as any, { + hostAnchorId: 'anchor-1', + hostUnitId: 'host-1', + clientX: 20, + clientY: 30, + button: 0, + })).toBe(true); + expect(shouldStartFloatDomMoveFromHandle(info as any, { + hostAnchorId: 'other-anchor', + hostUnitId: 'host-1', + clientX: 20, + clientY: 30, + button: 0, + })).toBe(false); + expect(shouldStartFloatDomMoveFromHandle(info as any, { + hostAnchorId: 'anchor-1', + hostUnitId: 'other-host', + clientX: 20, + clientY: 30, + button: 0, + })).toBe(false); + expect(shouldStartFloatDomMoveFromHandle(info as any, { + hostAnchorId: 'anchor-1', + hostUnitId: 'host-1', + clientX: 20, + clientY: 30, + button: 2, + })).toBe(false); + }); + + it('resolves host float dom drag deltas through the scene scale', () => { + const dragState = createFloatDomMoveDragState({ + rect: { + left: 100, + top: 40, + }, + } as any, { + pointerId: 1, + clientX: 20, + clientY: 30, + }); + + expect(dragState).toEqual(expect.objectContaining({ + pointerId: 1, + startLeft: 100, + startTop: 40, + })); + expect(resolveFloatDomMoveDragTransform(dragState!, { + clientX: 40, + clientY: 42, + } as any, { + getAncestorScale: () => ({ scaleX: 2, scaleY: 3 }), + } as any)).toEqual({ + left: 110, + top: 44, + }); + }); + + it('removes drawing-backed float doms through the shared remove path', () => { + const drawing = { + unitId: 'unit-1', + subUnitId: 'sheet-1', + drawingId: 'float-dom-1', + }; + const { service, dispose, disposeRenderObject, syncExecuteCommand, getDrawingByParam, getBatchRemoveOp, transformer } = createService(drawing); + + service.removeFloatDom('float-dom-1'); + + expect(dispose).toHaveBeenCalledTimes(1); + expect(disposeRenderObject).toHaveBeenCalledTimes(1); + expect(transformer.clearControlByIds).toHaveBeenCalledWith(['rect-1']); + expect(transformer.clearSelectedObjects).toHaveBeenCalledTimes(1); + expect(getDrawingByParam).toHaveBeenCalledWith({ + unitId: 'unit-1', + subUnitId: 'sheet-1', + drawingId: 'float-dom-1', + }); + expect(getBatchRemoveOp).toHaveBeenCalledWith([drawing]); + expect(syncExecuteCommand).toHaveBeenCalledWith(SetDrawingApplyMutation.id, { + unitId: 'unit-1', + subUnitId: 'sheet-1', + op: ['redo-op'], + objects: ['object-1'], + type: DrawingApplyType.REMOVE, + }); + expect(service.getFloatDomInfo('float-dom-1')).toBeUndefined(); + }); + + it('removes runtime-only float doms directly', () => { + const { service, dispose, disposeRenderObject, syncExecuteCommand, transformer } = createService(null); + + service.removeFloatDom('float-dom-1'); + + expect(dispose).toHaveBeenCalledTimes(1); + expect(disposeRenderObject).toHaveBeenCalledTimes(1); + expect(transformer.clearControlByIds).toHaveBeenCalledWith(['rect-1']); + expect(transformer.clearSelectedObjects).toHaveBeenCalledTimes(1); + expect(syncExecuteCommand).not.toHaveBeenCalled(); + expect(service.getFloatDomInfo('float-dom-1')).toBeUndefined(); + }); + afterEach(() => { while (disposables.length > 0) { const current = disposables.pop()!; @@ -600,7 +1250,7 @@ describe('SheetCanvasFloatDomManagerService', () => { ]); fixture.manager.updateFloatDomProps('test', 'sheet1', 'range-card', { fill: '#ff0000' }); - expect(fixture.manager.getFloatDomInfo('range-card')?.rect.fill).toBe('#ff0000'); + expect(fixture.manager.getFloatDomInfo('range-card')?.rect.toJson().fill).toBe('#ff0000'); rangeDom.dispose(); expect(findFloatDom(canvasFloatDomService, 'range-card')).toBeUndefined(); @@ -735,7 +1385,7 @@ describe('SheetCanvasFloatDomManagerService', () => { }, 'chart-card')!; const regularRect = fixture.manager.getFloatDomInfo('regular-card')?.rect; - const chartRect = fixture.manager.getFloatDomInfo('chart-card')?.rect; + const chartRect = fixture.manager.getFloatDomInfo('chart-card')?.rect as { fill?: string; stroke?: string } | undefined; expect(regularRect).toBeInstanceOf(Rect); expect(regularRect).not.toBeInstanceOf(TestChartRect); @@ -782,7 +1432,7 @@ describe('SheetCanvasFloatDomManagerService', () => { height: 72, }, 'chart-card')!; - const chartRect = fixture.manager.getFloatDomInfo('chart-card')?.rect; + const chartRect = fixture.manager.getFloatDomInfo('chart-card')?.rect as unknown as { fill?: string; stroke?: string } | undefined; expect(chartRect?.fill).toBe('#f5ead7'); expect(chartRect?.stroke).toBe('#111111'); @@ -1181,6 +1831,7 @@ describe('SheetCanvasFloatDomManagerService', () => { expect(findFloatDom(canvasFloatDomService, 'position-card')).toEqual(expect.objectContaining({ componentKey: 'PositionCard', data: { label: 'Pinned note' }, + preserveOnFocusChange: false, unitId: 'test', })); findFloatDom(canvasFloatDomService, 'position-card')?.onPointerDown(new MouseEvent('pointerdown')); @@ -1268,6 +1919,75 @@ describe('SheetCanvasFloatDomManagerService', () => { disposable.unsubscribe(); }); + it('creates the dom binding when the host rect already exists in the scene', async () => { + const fixture = setup(); + disposables.push(fixture); + const canvasFloatDomService = fixture.get(CanvasFloatDomService); + const drawingId = 'existing-rect-card'; + const rectShapeKey = getDrawingShapeKeyByDrawingSearch({ + unitId: 'test', + subUnitId: 'sheet1', + drawingId, + }); + + fixture.scene.addObject(new Rect(rectShapeKey, { + left: 76, + top: 30, + width: 100, + height: 40, + })); + + fixture.manager.addFloatDomToPosition({ + componentKey: 'ExistingRectCard', + initPosition: { + startX: 76, + startY: 30, + endX: 176, + endY: 70, + }, + data: { label: 'Existing rect' }, + allowTransform: true, + }, drawingId)!; + await Promise.resolve(); + + expect(findFloatDom(canvasFloatDomService, drawingId)).toEqual(expect.objectContaining({ + componentKey: 'ExistingRectCard', + data: { label: 'Existing rect' }, + unitId: 'test', + })); + expect(fixture.manager.getFloatDomInfo(drawingId)?.rect).toBe(fixture.scene.getObject(rectShapeKey)); + }); + + it('preserves embed float dom layers while child units own focus', async () => { + const fixture = setup(); + disposables.push(fixture); + const canvasFloatDomService = fixture.get(CanvasFloatDomService); + + fixture.manager.addFloatDomToPosition({ + componentKey: 'EmbedCard', + initPosition: { + startX: 76, + startY: 30, + endX: 176, + endY: 70, + }, + data: { + version: 1, + embedId: 'embed-doc', + hostType: UniverInstanceType.UNIVER_SHEET, + childType: UniverInstanceType.UNIVER_DOC, + runtimeMountMode: 'stage2', + }, + allowTransform: true, + }, 'embed-card')!; + await Promise.resolve(); + + expect(findFloatDom(canvasFloatDomService, 'embed-card')).toEqual(expect.objectContaining({ + componentKey: 'EmbedCard', + preserveOnFocusChange: true, + })); + }); + it('refreshes float dom position when the sheet viewport scrolls', () => { const fixture = setup(); disposables.push(fixture); @@ -1311,6 +2031,40 @@ describe('SheetCanvasFloatDomManagerService', () => { subscription.unsubscribe(); }); + + it('hides worksheet-scoped float doms when another sheet tab becomes active', async () => { + const fixture = setup(TWO_SHEET_WORKBOOK_DATA); + disposables.push(fixture); + fixture.get(LifecycleService).stage = LifecycleStages.Rendered; + const canvasFloatDomService = fixture.get(CanvasFloatDomService); + + fixture.manager.addFloatDomToPosition({ + componentKey: 'SheetScopedCard', + initPosition: { + startX: 76, + startY: 30, + endX: 176, + endY: 70, + }, + data: { label: 'Sheet scoped' }, + allowTransform: true, + }, 'sheet-scoped-card')!; + await Promise.resolve(); + + expect(findFloatDom(canvasFloatDomService, 'sheet-scoped-card')).toBeDefined(); + + (fixture.manager as unknown as { + _syncFloatDomVisibilityForActiveSheet: (unitId: string, activeSubUnitId: string) => void; + })._syncFloatDomVisibilityForActiveSheet('test', 'sheet2'); + + expect(findFloatDom(canvasFloatDomService, 'sheet-scoped-card')).toBeUndefined(); + + (fixture.manager as unknown as { + _syncFloatDomVisibilityForActiveSheet: (unitId: string, activeSubUnitId: string) => void; + })._syncFloatDomVisibilityForActiveSheet('test', 'sheet1'); + + expect(findFloatDom(canvasFloatDomService, 'sheet-scoped-card')).toBeDefined(); + }); }); function findFloatDom(canvasFloatDomService: CanvasFloatDomService, id: string): IFloatDom | undefined { @@ -1320,3 +2074,24 @@ function findFloatDom(canvasFloatDomService: CanvasFloatDomService, id: string): } } } + +function createStage1FloatDomInfo() { + return { + runtimeMounted: false, + runtimeStage: 'stage1', + position$: { + getValue: () => ({ + startX: 100, + endX: 420, + startY: 80, + endY: 260, + }), + }, + rect: { + left: 1000, + top: 800, + width: 320, + height: 180, + }, + }; +} diff --git a/packages/sheets-drawing-ui/src/services/canvas-float-dom-manager.service.ts b/packages/sheets-drawing-ui/src/services/canvas-float-dom-manager.service.ts index 20228cfba065..44e564f1c65b 100644 --- a/packages/sheets-drawing-ui/src/services/canvas-float-dom-manager.service.ts +++ b/packages/sheets-drawing-ui/src/services/canvas-float-dom-manager.service.ts @@ -18,16 +18,16 @@ import type { IDisposable, IDrawingSearch, IPosition, IRange, ITransformState, N import type { IDrawingJsonUndo1 } from '@univerjs/drawing'; import type { BaseObject, IBoundRectNoAngle, IRectProps, IRender, Scene, SpreadsheetSkeleton } from '@univerjs/engine-render'; import type { ISetFrozenMutationParams, ISetSelectionsOperationParams, ISetWorksheetRowAutoHeightMutationParams } from '@univerjs/sheets'; -import type { IFloatDomData, IInsertDrawingCommandParams, ISheetDrawingPosition, ISheetFloatDom } from '@univerjs/sheets-drawing'; +import type { IFloatDomData, IInsertDrawingCommandParams, ISetDrawingCommandParams, ISheetDrawing, ISheetDrawingPosition, ISheetFloatDom } from '@univerjs/sheets-drawing'; import type { IFloatDom, IFloatDomLayout } from '@univerjs/ui'; -import { Disposable, DisposableCollection, DrawingTypeEnum, fromEventSubject, generateRandomId, ICommandService, Inject, IUniverInstanceService, LifecycleService, LifecycleStages, Tools, UniverInstanceType } from '@univerjs/core'; +import { Disposable, DisposableCollection, DrawingTypeEnum, fromEventSubject, generateRandomId, ICommandService, Inject, IUniverInstanceService, LifecycleService, LifecycleStages, Optional, Tools, UniverInstanceType } from '@univerjs/core'; import { getDrawingShapeKeyByDrawingSearch, IDrawingManagerService } from '@univerjs/drawing'; import { disposeDrawingRenderObject, insertGroupObject } from '@univerjs/drawing-ui'; -import { DRAWING_OBJECT_LAYER_INDEX, IRenderManagerService, ObjectType, Rect, SHEET_VIEWPORT_KEY } from '@univerjs/engine-render'; +import { DRAWING_OBJECT_LAYER_INDEX, IRenderManagerService, ObjectType, Rect, Image as RenderImage, SHEET_VIEWPORT_KEY } from '@univerjs/engine-render'; import { COMMAND_LISTENER_SKELETON_CHANGE, getSheetCommandTarget, SetFrozenMutation, SetSelectionsOperation, SetWorksheetRowAutoHeightMutation } from '@univerjs/sheets'; -import { DrawingApplyType, InsertSheetDrawingCommand, ISheetDrawingService, SetDrawingApplyMutation } from '@univerjs/sheets-drawing'; +import { DrawingApplyType, InsertSheetDrawingCommand, ISheetDrawingService, SetDrawingApplyMutation, SetSheetDrawingCommand, transformToAxisAlignPosition, transformToDrawingPosition } from '@univerjs/sheets-drawing'; import { ISheetSelectionRenderService, SetScrollOperation, SetZoomRatioOperation, SheetSkeletonManagerService } from '@univerjs/sheets-ui'; -import { CanvasFloatDomService } from '@univerjs/ui'; +import { CanvasFloatDomPreviewService, CanvasFloatDomService } from '@univerjs/ui'; import { BehaviorSubject, filter, map, of, Subject, switchMap, take } from 'rxjs'; export interface ICanvasFloatDom { @@ -74,7 +74,7 @@ export const SHEET_FLOAT_DOM_PREFIX = 'univer-sheet-float-dom-'; export interface ICanvasFloatDomInfo { position$: BehaviorSubject; dispose: IDisposable; - rect: Rect; + rect: BaseObject; unitId: string; subUnitId: string; boundsOfViewArea?: IBoundRectNoAngle; @@ -82,6 +82,10 @@ export interface ICanvasFloatDomInfo { domAnchor?: IDOMAnchor; id: string; domId?: string; // Ensure unique id for dom element at runtime + floatDomConfig?: IFloatDom; + runtimeMounted?: boolean; + runtimeStage?: 'inactive' | 'stage1' | 'stage2'; + previewObjectKey?: string; } /** @@ -114,6 +118,30 @@ export interface ISheetFloatDomRenderObjectFactoryContext { */ export type SheetFloatDomRenderObjectFactory = (context: ISheetFloatDomRenderObjectFactoryContext) => Rect; +function createExternalRuntimeDisposable( + owner: TOwner, + id: string, + disposeById: (owner: TOwner, id: string) => void +): IDisposable & { id: string } { + const ownerRef = new WeakRef(owner); + let disposed = false; + + return { + id, + dispose() { + if (disposed) { + return; + } + + disposed = true; + const currentOwner = ownerRef.deref(); + if (currentOwner) { + disposeById(currentOwner, id); + } + }, + }; +} + export interface IDOMAnchor { width: number; height: number; @@ -123,6 +151,331 @@ export interface IDOMAnchor { marginY?: number | string; } +const SHEET_EMBED_FLOAT_DOM_TRANSFORMER_CONFIG = { + borderEnabled: true, + borderStroke: '#4086f4', + borderStrokeWidth: 1, + borderSpacing: 2, + anchorFill: '#ffffff', + anchorStroke: '#4086f4', + anchorStrokeWidth: 1.5, + anchorSize: 8, + anchorCornerRadius: 2, + anchorStyle: 'canva', + rotateEnabled: false, + resizeEnabled: true, + moveBoundaryEnabled: false, +} as const; + +const FLOAT_DOM_RUNTIME_ACTIVATION_EVENT_PRIORITY = -100; +const FLOAT_DOM_STAGE2_CLICK_DISTANCE_THRESHOLD = 4; +const FLOAT_DOM_PREVIEW_OBJECT_SUFFIX = '__preview'; +export const EMBED_FLOAT_DRAG_HANDLE_POINTER_DOWN_EVENT = 'univer:embed-float-drag-handle:pointerdown'; + +export interface IFloatDomHostClickIntent { + pointerId?: number; + startOffsetX: number; + startOffsetY: number; + startedAt: number; +} + +export interface IEmbedFloatDragHandlePointerDownDetail { + embedId?: string; + hostUnitId?: string; + hostAnchorId?: string; + pointerId?: number; + clientX?: number; + clientY?: number; + button?: number; +} + +export interface IFloatDomMoveDragState { + pointerId?: number; + startClientX: number; + startClientY: number; + startLeft: number; + startTop: number; +} + +export function shouldStartFloatDomMoveFromHandle( + info: Pick, + detail: IEmbedFloatDragHandlePointerDownDetail +): boolean { + return detail.hostAnchorId === info.id && + (detail.hostUnitId == null || detail.hostUnitId === info.unitId) && + (detail.button == null || detail.button === 0) && + typeof detail.clientX === 'number' && + typeof detail.clientY === 'number'; +} + +export function createFloatDomMoveDragState( + info: Pick, + detail: IEmbedFloatDragHandlePointerDownDetail +): IFloatDomMoveDragState | undefined { + if (typeof detail.clientX !== 'number' || typeof detail.clientY !== 'number') { + return undefined; + } + + return { + pointerId: detail.pointerId, + startClientX: detail.clientX, + startClientY: detail.clientY, + startLeft: Number(info.rect.left ?? 0), + startTop: Number(info.rect.top ?? 0), + }; +} + +export function resolveFloatDomMoveDragTransform( + state: IFloatDomMoveDragState, + event: Pick, + scene: Pick +): Pick { + const { scaleX, scaleY } = scene.getAncestorScale(); + return { + left: state.startLeft + (event.clientX - state.startClientX) / (scaleX || 1), + top: state.startTop + (event.clientY - state.startClientY) / (scaleY || 1), + }; +} + +export function applyFloatDomTransformerConfig(rect: BaseObject, floatDomParam: IFloatDomData): void { + const data = floatDomParam.data; + if (!data || typeof data !== 'object') { + return; + } + + const embedData = data as { + version?: number; + embedId?: string; + resizeBehavior?: string; + }; + if (embedData.version !== 1 || typeof embedData.embedId !== 'string') { + return; + } + + rect.transformerConfig = { + ...SHEET_EMBED_FLOAT_DOM_TRANSFORMER_CONFIG, + keepRatio: embedData.resizeBehavior === 'aspect-ratio', + }; +} + +function isStage2RuntimeEmbedFloatDom(floatDomParam: Pick): boolean { + if (!isEmbedFloatDomData(floatDomParam)) { + return false; + } + + const embedData = floatDomParam.data as { + hostType?: UniverInstanceType; + childType?: UniverInstanceType; + runtimeMountMode?: string; + }; + + return embedData.hostType === UniverInstanceType.UNIVER_SHEET && + embedData.childType === UniverInstanceType.UNIVER_SHEET && + embedData.runtimeMountMode === 'stage2'; +} + +export function isEmbedFloatDomData(floatDomParam: Pick): boolean { + const data = floatDomParam.data; + if (!data || typeof data !== 'object') { + return false; + } + + const embedData = data as { + version?: number; + embedId?: string; + }; + + return embedData.version === 1 && + typeof embedData.embedId === 'string'; +} + +export function isSheetHostedEmbedFloatDom(floatDomParam: Pick): boolean { + if (!isEmbedFloatDomData(floatDomParam)) { + return false; + } + + const embedData = floatDomParam.data as { + hostType?: UniverInstanceType; + childType?: UniverInstanceType; + }; + + return embedData.hostType === UniverInstanceType.UNIVER_SHEET && + embedData.childType != null; +} + +export function resolveSheetFloatDomRuntimePolicy( + floatDomParam: Pick, + stage: ICanvasFloatDomInfo['runtimeStage'] = 'inactive' +): { + autoMountRuntime: boolean; + passThroughRuntimeEvents: boolean; + preserveOnFocusChange: boolean; + usePreviewObject: boolean; +} { + const autoMountRuntime = !isStage2RuntimeEmbedFloatDom(floatDomParam); + const sheetHostedEmbed = isSheetHostedEmbedFloatDom(floatDomParam); + + return { + autoMountRuntime, + passThroughRuntimeEvents: !(sheetHostedEmbed && stage === 'stage2'), + preserveOnFocusChange: isEmbedFloatDomData(floatDomParam), + usePreviewObject: isStage2RuntimeEmbedFloatDom(floatDomParam), + }; +} + +export function shouldAutoMountFloatDomRuntime(floatDomParam: Pick): boolean { + return resolveSheetFloatDomRuntimePolicy(floatDomParam).autoMountRuntime; +} + +export function shouldPreserveFloatDomOnFocusChange(floatDomParam: Pick): boolean { + return resolveSheetFloatDomRuntimePolicy(floatDomParam).preserveOnFocusChange; +} + +export function shouldUseFloatDomPreviewObject(floatDomParam: Pick): boolean { + return resolveSheetFloatDomRuntimePolicy(floatDomParam).usePreviewObject; +} + +export function shouldPassThroughFloatDomRuntimeEvents( + floatDomParam: Pick, + stage: ICanvasFloatDomInfo['runtimeStage'] = 'inactive' +): boolean { + return resolveSheetFloatDomRuntimePolicy(floatDomParam, stage).passThroughRuntimeEvents; +} + +export function shouldUpdateFloatDomLayerOnRuntimeStageChange(floatDomParam: Pick): boolean { + return isEmbedFloatDomData(floatDomParam) && !shouldAutoMountFloatDomRuntime(floatDomParam); +} + +export function shouldForwardSheetHostedEmbedFloatDomEvent( + floatDomParam: Pick, + event: Pick +): boolean { + if (!isSheetHostedEmbedFloatDom(floatDomParam)) { + return true; + } + + const target = event.target as { closest?: (selector: string) => HTMLElement | null } | null; + const runtime = target?.closest?.('[data-embed-float-dom="true"]'); + return runtime?.dataset?.embedFloatStage !== 'stage2'; +} + +export function shouldPassThroughFloatDomActivationEvent(nextStage: ICanvasFloatDomInfo['runtimeStage'] | undefined): boolean { + return nextStage !== 'stage2'; +} + +export function syncFloatDomHostSelectionOnStageEnter( + stage: ICanvasFloatDomInfo['runtimeStage'] | undefined, + renderObject: { + transformer: { clearControlByIds: (ids: string[]) => void }; + scene: { + attachTransformerTo?: (object: BaseObject) => void; + getTransformer?: () => Nullable<{ clearSelectedObjects?: () => void }>; + }; + } | null | undefined, + rect: BaseObject & { oKey?: string } +): void { + if (!renderObject) { + return; + } + + if (stage === 'stage1') { + renderObject.scene.attachTransformerTo?.(rect); + return; + } + + if (stage === 'stage2' && rect.oKey) { + renderObject.transformer.clearControlByIds([rect.oKey]); + renderObject.scene.getTransformer?.()?.clearSelectedObjects?.(); + } +} + +function isFloatDomInDomLayer( + canvasFloatDomService: Pick, + id: string +): boolean { + return canvasFloatDomService.domLayers.some(([layerId]) => layerId === id); +} + +export function isCanvasFloatDomDrawingType(drawingType: DrawingTypeEnum): boolean { + return drawingType === DrawingTypeEnum.DRAWING_DOM || + drawingType === DrawingTypeEnum.DRAWING_BLOCK || + drawingType === DrawingTypeEnum.DRAWING_CHART; +} + +export function shouldActivateStage2FromHostPointer( + info: Pick, + event: { offsetX?: number; offsetY?: number } +): boolean { + if (info.runtimeMounted || info.runtimeStage !== 'stage1') { + return false; + } + + const { offsetX, offsetY } = event; + if (typeof offsetX !== 'number' || typeof offsetY !== 'number') { + return false; + } + + const position = info.position$.getValue(); + if ( + offsetX >= Math.min(position.startX, position.endX) && + offsetX <= Math.max(position.startX, position.endX) && + offsetY >= Math.min(position.startY, position.endY) && + offsetY <= Math.max(position.startY, position.endY) + ) { + return true; + } + + const rect = info.rect; + if (typeof rect.isHit === 'function') { + try { + return rect.isHit({ x: offsetX, y: offsetY } as any); + } catch { + // Fall back to the axis-aligned bounds when the render object expects + // richer vector instances than a host-level event can provide. + } + } + + return offsetX >= rect.left && + offsetX <= rect.left + rect.width && + offsetY >= rect.top && + offsetY <= rect.top + rect.height; +} + +export function createFloatDomHostClickIntent( + info: Pick, + event: { type?: string; pointerId?: number; offsetX?: number; offsetY?: number } +): IFloatDomHostClickIntent | undefined { + if (event.type !== 'pointerdown' || !shouldActivateStage2FromHostPointer(info, event)) { + return undefined; + } + + return { + pointerId: event.pointerId, + startOffsetX: event.offsetX!, + startOffsetY: event.offsetY!, + startedAt: Date.now(), + }; +} + +export function shouldActivateStage2FromHostClickIntent( + info: Pick, + intent: IFloatDomHostClickIntent | undefined, + event: { type?: string; pointerId?: number; offsetX?: number; offsetY?: number } +): boolean { + if (!intent || event.type !== 'pointerup') { + return false; + } + if (intent.pointerId != null && event.pointerId != null && intent.pointerId !== event.pointerId) { + return false; + } + if (!shouldActivateStage2FromHostPointer(info, event)) { + return false; + } + + const distance = Math.hypot(event.offsetX! - intent.startOffsetX, event.offsetY! - intent.startOffsetY); + return distance <= FLOAT_DOM_STAGE2_CLICK_DISTANCE_THRESHOLD; +} + export interface ILimitBound extends IBoundRectNoAngle { /** * Actually, it means fixed. @@ -322,13 +675,15 @@ export class SheetCanvasFloatDomManagerService extends Disposable { @IDrawingManagerService private _drawingManagerService: IDrawingManagerService, @Inject(CanvasFloatDomService) private readonly _canvasFloatDomService: CanvasFloatDomService, @ISheetDrawingService private readonly _sheetDrawingService: ISheetDrawingService, - @Inject(LifecycleService) protected readonly _lifecycleService: LifecycleService + @Inject(LifecycleService) protected readonly _lifecycleService: LifecycleService, + @Optional(CanvasFloatDomPreviewService) private readonly _canvasFloatDomPreviewService?: CanvasFloatDomPreviewService ) { super(); this._drawingAddListener(); this._featureUpdateListener(); this._deleteListener(); this._bindScrollEvent(); + this._bindEmbedFloatDragHandleEvent(); } /** @@ -381,6 +736,328 @@ export class SheetCanvasFloatDomManagerService extends Disposable { return Array.from(this._domLayerInfoMap.values()).filter((info) => info.subUnitId === subUnitId && info.unitId === unitId); } + private static _disposeExternalFloatDom(manager: SheetCanvasFloatDomManagerService, id: string): void { + manager._removeDom(id, true); + } + + private _createFloatDomDisposable(id: string): IDisposable & { id: string } { + return createExternalRuntimeDisposable(this, id, SheetCanvasFloatDomManagerService._disposeExternalFloatDom); + } + + private _bindEmbedFloatDragHandleEvent(): void { + if (typeof document === 'undefined') { + return; + } + + const listener = (event: Event) => this._handleEmbedFloatDragHandlePointerDown(event as CustomEvent); + document.addEventListener(EMBED_FLOAT_DRAG_HANDLE_POINTER_DOWN_EVENT, listener); + this.disposeWithMe(() => document.removeEventListener(EMBED_FLOAT_DRAG_HANDLE_POINTER_DOWN_EVENT, listener)); + } + + private _handleEmbedFloatDragHandlePointerDown(event: CustomEvent): void { + const detail = event.detail; + if (!detail?.hostAnchorId) { + return; + } + + const info = this._domLayerInfoMap.get(detail.hostAnchorId); + if (!info || !shouldStartFloatDomMoveFromHandle(info, detail)) { + return; + } + + const dragState = createFloatDomMoveDragState(info, detail); + const renderObject = this._getSceneAndTransformerByDrawingSearch(info.unitId); + if (!dragState || !renderObject) { + return; + } + + const { scene, transformer } = renderObject; + if (info.rect.oKey) { + transformer.clearControlByIds([info.rect.oKey]); + scene.getTransformer()?.clearSelectedObjects(); + } + + const handlePointerMove = (pointerEvent: PointerEvent) => { + if (dragState.pointerId != null && pointerEvent.pointerId !== dragState.pointerId) { + return; + } + + pointerEvent.preventDefault(); + const nextTransform = resolveFloatDomMoveDragTransform(dragState, pointerEvent, scene); + info.rect.transformByState(nextTransform as ITransformState); + }; + const handlePointerUp = (pointerEvent: PointerEvent) => { + if (dragState.pointerId != null && pointerEvent.pointerId !== dragState.pointerId) { + return; + } + + window.removeEventListener('pointermove', handlePointerMove, true); + window.removeEventListener('pointerup', handlePointerUp, true); + window.removeEventListener('pointercancel', handlePointerCancel, true); + + pointerEvent.preventDefault(); + if (info.rect.left !== dragState.startLeft || info.rect.top !== dragState.startTop) { + this._commitFloatDomMove(info); + } + if (info.runtimeStage === 'stage1') { + scene.attachTransformerTo?.(info.rect); + } + }; + const handlePointerCancel = (pointerEvent: PointerEvent) => { + if (dragState.pointerId != null && pointerEvent.pointerId !== dragState.pointerId) { + return; + } + + window.removeEventListener('pointermove', handlePointerMove, true); + window.removeEventListener('pointerup', handlePointerUp, true); + window.removeEventListener('pointercancel', handlePointerCancel, true); + info.rect.transformByState({ + left: dragState.startLeft, + top: dragState.startTop, + } as ITransformState); + if (info.runtimeStage === 'stage1') { + scene.attachTransformerTo?.(info.rect); + } + }; + + window.addEventListener('pointermove', handlePointerMove, true); + window.addEventListener('pointerup', handlePointerUp, true); + window.addEventListener('pointercancel', handlePointerCancel, true); + } + + private _commitFloatDomMove(info: ICanvasFloatDomInfo): void { + const skeletonParam = this._renderManagerService.getRenderById(info.unitId)?.with(SheetSkeletonManagerService).getSkeletonParam(info.subUnitId); + const drawing = this._sheetDrawingService.getDrawingByParam({ + unitId: info.unitId, + subUnitId: info.subUnitId, + drawingId: info.id, + }) as ISheetDrawing | undefined; + if (!skeletonParam || !drawing?.transform) { + return; + } + + const transform = { + ...drawing.transform, + left: info.rect.left, + top: info.rect.top, + width: info.rect.width, + height: info.rect.height, + angle: info.rect.angle, + flipX: info.rect.flipX, + flipY: info.rect.flipY, + skewX: info.rect.skewX, + skewY: info.rect.skewY, + } as ITransformState; + const sheetTransform = transformToDrawingPosition(transform, skeletonParam.skeleton); + const axisAlignSheetTransform = transformToAxisAlignPosition(transform, skeletonParam.skeleton); + if (!sheetTransform || !axisAlignSheetTransform) { + return; + } + + this._commandService.syncExecuteCommand(SetSheetDrawingCommand.id, { + unitId: info.unitId, + drawings: [{ + ...drawing, + transform, + sheetTransform, + axisAlignSheetTransform, + }], + }); + } + + isFloatDomRuntimeMounted(id: string): boolean { + return this._domLayerInfoMap.get(id)?.runtimeMounted === true; + } + + private _getFloatDomPreviewObjectKey(rectShapeKey: string): string { + return `${rectShapeKey}${FLOAT_DOM_PREVIEW_OBJECT_SUFFIX}`; + } + + private _syncPreviewObjectTransform(previewObject: Nullable, rect: BaseObject): void { + previewObject?.transformByState({ + left: rect.left, + top: rect.top, + width: rect.width, + height: rect.height, + angle: rect.angle, + flipX: rect.flipX, + flipY: rect.flipY, + skewX: rect.skewX, + skewY: rect.skewY, + } as ITransformState); + } + + private _requestFloatDomPreview(drawingId: string, rect: BaseObject, data: Serializable | undefined): void { + if (!this._canvasFloatDomPreviewService) { + return; + } + + this._canvasFloatDomPreviewService.requestPreview({ + id: drawingId, + width: rect.width, + height: rect.height, + data, + }); + } + + private _upsertFloatDomPreviewObject( + scene: Scene, + rect: BaseObject, + rectShapeKey: string, + drawingId: string, + data: Serializable | undefined + ): BaseObject | undefined { + const preview = this._canvasFloatDomPreviewService?.getPreview(drawingId); + if (!preview?.image) { + this._requestFloatDomPreview(drawingId, rect, data); + return undefined; + } + + const previewObjectKey = this._getFloatDomPreviewObjectKey(rectShapeKey); + const existingPreviewObject = scene.getObject(previewObjectKey); + if (existingPreviewObject instanceof RenderImage) { + existingPreviewObject.changeSource(preview.image); + this._syncPreviewObjectTransform(existingPreviewObject, rect); + return existingPreviewObject; + } + + const previewObject = new RenderImage(previewObjectKey, { + left: rect.left, + top: rect.top, + width: rect.width, + height: rect.height, + angle: rect.angle, + flipX: rect.flipX, + flipY: rect.flipY, + skewX: rect.skewX, + skewY: rect.skewY, + url: preview.image, + evented: false, + rotateEnabled: false, + resizeEnabled: false, + }); + scene.addObject(previewObject, DRAWING_OBJECT_LAYER_INDEX); + return previewObject; + } + + mountFloatDomRuntime(id: string): boolean { + const info = this._domLayerInfoMap.get(id); + if (!info?.floatDomConfig) { + return false; + } + + if (info.runtimeMounted) { + return true; + } + + if (isFloatDomInDomLayer(this._canvasFloatDomService, id)) { + this._canvasFloatDomService.updateFloatDom(id, { + eventPassThrough: false, + props: { + ...info.floatDomConfig.props, + initialStage: 'stage2', + onRuntimeStageExit: () => this.unmountFloatDomRuntime(id), + }, + }); + } else { + this._canvasFloatDomService.addFloatDom({ + ...info.floatDomConfig, + eventPassThrough: false, + props: { + ...info.floatDomConfig.props, + initialStage: 'stage2', + onRuntimeStageExit: () => this.unmountFloatDomRuntime(id), + }, + }); + } + info.runtimeMounted = true; + info.runtimeStage = 'stage2'; + return true; + } + + unmountFloatDomRuntime(id: string): void { + const info = this._domLayerInfoMap.get(id); + if (!info?.runtimeMounted) { + return; + } + + if (info.floatDomConfig && !shouldAutoMountFloatDomRuntime(info.floatDomConfig)) { + this._canvasFloatDomService.updateFloatDom(id, { + eventPassThrough: shouldPassThroughFloatDomRuntimeEvents(info.floatDomConfig, 'inactive'), + props: info.floatDomConfig.props, + }); + } else { + this._canvasFloatDomService.removeFloatDom(id); + } + info.runtimeMounted = false; + info.runtimeStage = 'inactive'; + } + + private _syncFloatDomVisibilityForActiveSheet(unitId: string, activeSubUnitId: string): void { + Array.from(this._domLayerInfoMap.values()) + .filter((info) => info.unitId === unitId && info.floatDomConfig) + .forEach((info) => { + const isActiveSheet = info.subUnitId === activeSubUnitId; + const isInDomLayer = isFloatDomInDomLayer(this._canvasFloatDomService, info.id); + + if (!isActiveSheet) { + if (isInDomLayer) { + this._canvasFloatDomService.removeFloatDom(info.id); + } + info.runtimeMounted = false; + info.runtimeStage = 'inactive'; + return; + } + + if (isInDomLayer || !info.floatDomConfig) { + return; + } + + this._canvasFloatDomService.addFloatDom(info.floatDomConfig); + const shouldAutoMountRuntime = shouldAutoMountFloatDomRuntime(info.floatDomConfig); + info.runtimeMounted = shouldAutoMountRuntime; + info.runtimeStage = isEmbedFloatDomData(info.floatDomConfig) + ? 'inactive' + : shouldAutoMountRuntime ? 'stage2' : 'inactive'; + }); + } + + promoteFloatDomRuntimeStage(id: string): ICanvasFloatDomInfo['runtimeStage'] | undefined { + const info = this._domLayerInfoMap.get(id); + if (!info?.floatDomConfig) { + return undefined; + } + + const sheetHostedEmbed = isSheetHostedEmbedFloatDom(info.floatDomConfig); + if (info.runtimeMounted && !sheetHostedEmbed) { + info.runtimeStage = 'stage2'; + return 'stage2'; + } + + if (info.runtimeStage !== 'stage1') { + info.runtimeStage = 'stage1'; + if (shouldUpdateFloatDomLayerOnRuntimeStageChange(info.floatDomConfig)) { + this._canvasFloatDomService.updateFloatDom(id, { + eventPassThrough: shouldPassThroughFloatDomRuntimeEvents(info.floatDomConfig, 'stage1'), + }); + } + return 'stage1'; + } + + if (info.runtimeMounted) { + info.runtimeStage = 'stage2'; + if (shouldUpdateFloatDomLayerOnRuntimeStageChange(info.floatDomConfig)) { + this._canvasFloatDomService.updateFloatDom(id, { + eventPassThrough: shouldPassThroughFloatDomRuntimeEvents(info.floatDomConfig, 'stage2'), + }); + } + return 'stage2'; + } + + this.mountFloatDomRuntime(id); + return 'stage2'; + } + private _getSceneAndTransformerByDrawingSearch(unitId: Nullable) { if (unitId == null) { return; @@ -429,7 +1106,7 @@ export class SheetCanvasFloatDomManagerService extends Disposable { const { transform, drawingType, data, hidden, groupId } = floatDomParam; - if (drawingType !== DrawingTypeEnum.DRAWING_DOM && drawingType !== DrawingTypeEnum.DRAWING_CHART) { + if (!isCanvasFloatDomDrawingType(drawingType)) { return; } @@ -461,9 +1138,16 @@ export class SheetCanvasFloatDomManagerService extends Disposable { if (rectShape != null) { this._removeTopLevelDuplicateIfGrouped(scene, rectShapeKey, rectShape); + applyFloatDomTransformerConfig(rectShape, floatDomParam); rectShape.transformByState({ left, top, width, height, angle, flipX, flipY, skewX, skewY }); this._syncFloatDomRect(drawingId, rectShape); - return; + if (shouldUseFloatDomPreviewObject(floatDomParam)) { + this._syncPreviewObjectTransform(scene.getObject(this._getFloatDomPreviewObjectKey(rectShapeKey)), rectShape); + this._requestFloatDomPreview(drawingId, rectShape, data); + } + if (this._domLayerInfoMap.has(drawingId)) { + return; + } } const imageConfig: IRectProps = { @@ -492,7 +1176,7 @@ export class SheetCanvasFloatDomManagerService extends Disposable { imageConfig.radius = 8; } - const rect = this._createRenderObject({ + const rect = rectShape ?? this._createRenderObject({ key: rectShapeKey, config: imageConfig, unitId, @@ -501,65 +1185,200 @@ export class SheetCanvasFloatDomManagerService extends Disposable { drawingType, data, }); + applyFloatDomTransformerConfig(rect, floatDomParam); if (isChart) { - rect.setObjectType(ObjectType.CHART); - } else if (drawingType === DrawingTypeEnum.DRAWING_DOM) { - rect.setObjectType(ObjectType.DRAWING_DOM); + rect.objectType = ObjectType.CHART; + } else if (drawingType === DrawingTypeEnum.DRAWING_DOM || drawingType === DrawingTypeEnum.DRAWING_BLOCK) { + rect.objectType = ObjectType.DRAWING_DOM; } - scene.addObject(rect, DRAWING_OBJECT_LAYER_INDEX); - if (floatDomParam.allowTransform !== false) { + if (!rectShape) { + scene.addObject(rect, DRAWING_OBJECT_LAYER_INDEX); + } + if (!rectShape && floatDomParam.allowTransform !== false) { scene.attachTransformerTo(rect); } - if (isChart && groupId) { + if (!rectShape && isChart && groupId) { insertGroupObject({ drawingId: groupId, unitId, subUnitId }, rect, scene, this._drawingManagerService); } const disposableCollection = new DisposableCollection(); + const shouldUsePreviewObject = shouldUseFloatDomPreviewObject(floatDomParam); + const previewObjectKey = shouldUsePreviewObject ? this._getFloatDomPreviewObjectKey(rectShapeKey) : undefined; + if (shouldUsePreviewObject) { + this._upsertFloatDomPreviewObject(scene, rect, rectShapeKey, drawingId, data); + const previewSubscription = this._canvasFloatDomPreviewService?.previewUpdated$.subscribe((preview) => { + if (preview.id !== drawingId) { + return; + } + + this._upsertFloatDomPreviewObject(scene, rect, rectShapeKey, drawingId, data); + }); + previewSubscription && disposableCollection.add(previewSubscription); + } const initPosition = calcSheetFloatDomPosition(rect, renderObject.renderUnit.scene, skeleton.skeleton, target.worksheet); const position$ = new BehaviorSubject(initPosition); const domId = `${SHEET_FLOAT_DOM_PREFIX}${generateRandomId(6)}`; - const info: ICanvasFloatDomInfo = { - dispose: disposableCollection, - rect, - position$, - unitId, - subUnitId, - id: drawingId, - domId, + const shouldAutoMountRuntime = shouldAutoMountFloatDomRuntime(floatDomParam); + const shouldSyncEmbedRuntimeStage = isEmbedFloatDomData(floatDomParam); + let info: ICanvasFloatDomInfo | undefined; + const handleRuntimeStageEnter = (stage: ICanvasFloatDomInfo['runtimeStage']) => { + if (info) { + info.runtimeStage = stage; + info.runtimeMounted = shouldAutoMountRuntime || stage === 'stage2'; + if (shouldUpdateFloatDomLayerOnRuntimeStageChange(floatDomParam)) { + this._canvasFloatDomService.updateFloatDom(drawingId, { + eventPassThrough: shouldPassThroughFloatDomRuntimeEvents(floatDomParam, stage), + }); + } + } + const currentRenderObject = this._getSceneAndTransformerByDrawingSearch(unitId); + syncFloatDomHostSelectionOnStageEnter(stage, currentRenderObject, rect); }; - - this._canvasFloatDomService.addFloatDom({ + const floatDomConfig: IFloatDom = { position$, id: drawingId, domId, componentKey: floatDomParam.componentKey, + eventPassThrough: shouldPassThroughFloatDomRuntimeEvents(floatDomParam, 'inactive'), + preserveOnFocusChange: shouldPreserveFloatDomOnFocusChange(floatDomParam), onPointerDown: (evt) => { - canvas.dispatchEvent(new PointerEvent(evt.type, evt)); + if (shouldForwardSheetHostedEmbedFloatDomEvent(floatDomParam, evt)) { + canvas.dispatchEvent(new PointerEvent(evt.type, evt)); + } }, onPointerMove: (evt: PointerEvent | MouseEvent) => { - canvas.dispatchEvent(new PointerEvent(evt.type, evt)); + if (shouldForwardSheetHostedEmbedFloatDomEvent(floatDomParam, evt)) { + canvas.dispatchEvent(new PointerEvent(evt.type, evt)); + } }, onPointerUp: (evt: PointerEvent | MouseEvent) => { - canvas.dispatchEvent(new PointerEvent(evt.type, evt)); + if (shouldForwardSheetHostedEmbedFloatDomEvent(floatDomParam, evt)) { + canvas.dispatchEvent(new PointerEvent(evt.type, evt)); + } }, onWheel: (evt: WheelEvent) => { - canvas.dispatchEvent(new WheelEvent(evt.type, evt)); + if (shouldForwardSheetHostedEmbedFloatDomEvent(floatDomParam, evt)) { + canvas.dispatchEvent(new WheelEvent(evt.type, evt)); + } }, data, + props: shouldSyncEmbedRuntimeStage + ? { + onRuntimeStageEnter: handleRuntimeStageEnter, + } + : undefined, unitId, - }); + }; + info = { + dispose: disposableCollection, + rect, + position$, + unitId, + subUnitId, + id: drawingId, + domId, + floatDomConfig, + runtimeMounted: shouldAutoMountRuntime, + runtimeStage: shouldSyncEmbedRuntimeStage + ? 'inactive' + : shouldAutoMountRuntime ? 'stage2' : 'inactive', + previewObjectKey, + }; + + this._canvasFloatDomService.addFloatDom(floatDomConfig); + if (!shouldAutoMountRuntime) { + let hostStage2ClickIntent: IFloatDomHostClickIntent | undefined; + const cancelStage2ClickIntentOnMove = (evt: { pointerId?: number; offsetX?: number; offsetY?: number }) => { + if (!hostStage2ClickIntent) { + return; + } + if (hostStage2ClickIntent.pointerId != null && evt.pointerId != null && hostStage2ClickIntent.pointerId !== evt.pointerId) { + return; + } + if (typeof evt.offsetX !== 'number' || typeof evt.offsetY !== 'number') { + hostStage2ClickIntent = undefined; + return; + } + + const distance = Math.hypot(evt.offsetX - hostStage2ClickIntent.startOffsetX, evt.offsetY - hostStage2ClickIntent.startOffsetY); + if (distance > FLOAT_DOM_STAGE2_CLICK_DISTANCE_THRESHOLD) { + hostStage2ClickIntent = undefined; + } + }; + const runtimeActivationListener = rect.onPointerDown$.subscribeEvent({ + priority: FLOAT_DOM_RUNTIME_ACTIVATION_EVENT_PRIORITY, + next: ([evt, state]) => { + if (info?.runtimeStage === 'stage1') { + hostStage2ClickIntent = createFloatDomHostClickIntent(info, evt); + return; + } + + const nextStage = this.promoteFloatDomRuntimeStage(drawingId); + if (nextStage === 'stage2') { + syncFloatDomHostSelectionOnStageEnter(nextStage, renderObject, rect); + state.skipNextObservers = true; + } + if (!shouldPassThroughFloatDomActivationEvent(nextStage)) { + state.stopPropagation(); + } + }, + }); + const sceneActivationListener = scene.onPointerDown$.subscribeEvent({ + priority: FLOAT_DOM_RUNTIME_ACTIVATION_EVENT_PRIORITY, + next: ([evt]) => { + if (!info) { + return; + } + + hostStage2ClickIntent = createFloatDomHostClickIntent(info, evt); + }, + }); + const sceneMoveListener = scene.onPointerMove$.subscribeEvent({ + priority: FLOAT_DOM_RUNTIME_ACTIVATION_EVENT_PRIORITY, + next: ([evt]) => { + cancelStage2ClickIntentOnMove(evt); + }, + }); + const sceneUpListener = scene.onPointerUp$.subscribeEvent({ + priority: FLOAT_DOM_RUNTIME_ACTIVATION_EVENT_PRIORITY, + next: ([evt, state]) => { + const shouldActivate = info && shouldActivateStage2FromHostClickIntent(info, hostStage2ClickIntent, evt); + hostStage2ClickIntent = undefined; + if (!shouldActivate) { + return; + } + + const nextStage = this.promoteFloatDomRuntimeStage(drawingId); + if (nextStage === 'stage2') { + syncFloatDomHostSelectionOnStageEnter(nextStage, renderObject, rect); + state.stopPropagation(); + state.skipNextObservers = true; + } + }, + }); + disposableCollection.add(runtimeActivationListener); + disposableCollection.add(sceneActivationListener); + disposableCollection.add(sceneMoveListener); + disposableCollection.add(sceneUpListener); + } const listener = rect.onTransformChange$.subscribeEvent(() => { const newPosition = calcSheetFloatDomPosition(rect, renderObject.renderUnit.scene, skeleton.skeleton, target.worksheet); position$.next( newPosition ); + if (previewObjectKey) { + this._syncPreviewObjectTransform(scene.getObject(previewObjectKey), rect); + } }); disposableCollection.add(() => { this._canvasFloatDomService.removeFloatDom(drawingId); + if (previewObjectKey) { + scene.removeObject(previewObjectKey); + } }); listener && disposableCollection.add(listener); this._domLayerInfoMap.set(drawingId, info); @@ -610,6 +1429,21 @@ export class SheetCanvasFloatDomManagerService extends Disposable { }); }; + this.disposeWithMe( + this._univerInstanceService.getCurrentTypeOfUnit$(UniverInstanceType.UNIVER_SHEET).pipe( + switchMap((workbook) => workbook ? workbook.activeSheet$ : of(null)) + ).subscribe((worksheet) => { + if (!worksheet) { + return; + } + + const unitId = worksheet.getUnitId(); + const subUnitId = worksheet.getSheetId(); + this._syncFloatDomVisibilityForActiveSheet(unitId, subUnitId); + updateSheet(unitId, subUnitId); + }) + ); + // #region scroll this.disposeWithMe( this._univerInstanceService.getCurrentTypeOfUnit$(UniverInstanceType.UNIVER_SHEET).pipe( @@ -785,7 +1619,7 @@ export class SheetCanvasFloatDomManagerService extends Disposable { return; } - if (sheetDrawing.drawingType !== DrawingTypeEnum.DRAWING_DOM && sheetDrawing.drawingType !== DrawingTypeEnum.DRAWING_CHART) { + if (!isCanvasFloatDomDrawingType(sheetDrawing.drawingType)) { return; } @@ -880,12 +1714,7 @@ export class SheetCanvasFloatDomManagerService extends Disposable { this._add$.next({ unitId, subUnitId, id }); - return { - id, - dispose: () => { - this._removeDom(id, true); - }, - }; + return this._createFloatDomDisposable(id); } private _removeDom(id: string, removeDrawing = false) { @@ -997,7 +1826,7 @@ export class SheetCanvasFloatDomManagerService extends Disposable { const { transform, drawingType, data, groupId } = floatDomParam; - if (drawingType !== DrawingTypeEnum.DRAWING_DOM && drawingType !== DrawingTypeEnum.DRAWING_CHART) { + if (!isCanvasFloatDomDrawingType(drawingType)) { return; } @@ -1181,12 +2010,7 @@ export class SheetCanvasFloatDomManagerService extends Disposable { this._domLayerInfoMap.set(drawingId, floatDomInfo); } - return { - id, - dispose: () => { - this._removeDom(id, true); - }, - }; + return this._createFloatDomDisposable(id); } // eslint-disable-next-line max-lines-per-function, complexity @@ -1426,12 +2250,7 @@ export class SheetCanvasFloatDomManagerService extends Disposable { this._domLayerInfoMap.set(drawingId, floatDomInfo); } - return { - id, - dispose: () => { - this._removeDom(id, true); - }, - }; + return this._createFloatDomDisposable(id); } /** @@ -1466,7 +2285,10 @@ export class SheetCanvasFloatDomManagerService extends Disposable { }; const disposable = new DisposableCollection(); - disposable.add(currentRender.engine.clientRect$.subscribe(() => updatePosition())); + disposable.add(currentRender.engine.clientRect$.subscribe({ + next: () => updatePosition(), + error: () => {}, + })); disposable.add(this._commandService.onCommandExecuted((commandInfo) => { if (commandInfo.id === SetWorksheetRowAutoHeightMutation.id) { diff --git a/packages/sheets-formula-ui/src/views/formula-editor/formula-embed-integration.service.ts b/packages/sheets-formula-ui/src/views/formula-editor/formula-embed-integration.service.ts new file mode 100644 index 000000000000..e15923b47253 --- /dev/null +++ b/packages/sheets-formula-ui/src/views/formula-editor/formula-embed-integration.service.ts @@ -0,0 +1,88 @@ +/** + * 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 type { IDisposable } from '@univerjs/core'; +import { createIdentifier } from '@univerjs/core'; + +export const FORMULA_EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE = 'data-embed-interaction-boundary-owner'; +export const FORMULA_EMBED_RUNTIME_FOCUS_ROLE_ATTRIBUTE = 'data-embed-runtime-focus-role'; +export const FORMULA_EMBED_ID_ATTRIBUTE = 'data-embed-id'; +export const FORMULA_EMBED_HOST_UNIT_ID_ATTRIBUTE = 'data-embed-host-unit-id'; +export const FORMULA_EMBED_CHILD_UNIT_ID_ATTRIBUTE = 'data-embed-child-unit-id'; + +export interface IFormulaEmbedRuntimeDomScope { + embedId: string; + hostUnitId?: string; + childUnitId?: string; +} + +export interface IFormulaEmbedRuntimeFocusCoordinator { + resolveRuntimeScopeByChildUnitId(childUnitId: string): IFormulaEmbedRuntimeDomScope | undefined; + acquireLease(options: { + embedId: string; + role: string; + owner: string; + hostUnitId?: string; + childUnitId?: string; + associatedChildUnitIds?: string[]; + }): IDisposable; + registerElement(options: { + embedId: string; + role: string; + element: HTMLElement; + }): IDisposable; +} + +export const IFormulaEmbedRuntimeFocusCoordinator = createIdentifier('sheets-formula-ui.embed-runtime-focus-coordinator'); + +export interface IFormulaEmbedInteractionBoundaryService { + registerOwnedElement(embedId: string, element: Element): IDisposable; +} + +export const IFormulaEmbedInteractionBoundaryService = createIdentifier('sheets-formula-ui.embed-interaction-boundary.service'); + +export function resolveFormulaEmbedRuntimeDomScope(root: HTMLElement | null | undefined): IFormulaEmbedRuntimeDomScope | undefined { + const scopeElement = root?.closest(`[${FORMULA_EMBED_ID_ATTRIBUTE}]`); + const embedId = scopeElement?.getAttribute(FORMULA_EMBED_ID_ATTRIBUTE); + if (!scopeElement || !embedId) { + return undefined; + } + + return { + embedId, + hostUnitId: scopeElement.getAttribute(FORMULA_EMBED_HOST_UNIT_ID_ATTRIBUTE) ?? undefined, + childUnitId: scopeElement.getAttribute(FORMULA_EMBED_CHILD_UNIT_ID_ATTRIBUTE) ?? undefined, + }; +} + +export function resolveActiveFormulaEmbedRuntimeDomScope(ownerDocument: Document | undefined): IFormulaEmbedRuntimeDomScope | undefined { + const activeElement = ownerDocument?.activeElement; + return activeElement instanceof HTMLElement ? resolveFormulaEmbedRuntimeDomScope(activeElement) : undefined; +} + +export function isEventTargetInSameFormulaEmbedInteractionBoundary(left: EventTarget | null | undefined, right: EventTarget | null | undefined): boolean { + const leftOwner = resolveFormulaEmbedInteractionOwnerId(left); + return Boolean(leftOwner && leftOwner === resolveFormulaEmbedInteractionOwnerId(right)); +} + +function resolveFormulaEmbedInteractionOwnerId(target: EventTarget | null | undefined): string | undefined { + if (!(target instanceof Element)) { + return undefined; + } + + return target.closest(`[${FORMULA_EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE}]`) + ?.getAttribute(FORMULA_EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE) ?? undefined; +} diff --git a/packages/sheets-formula-ui/src/views/formula-editor/hooks/__tests__/formula-selection-business.spec.ts b/packages/sheets-formula-ui/src/views/formula-editor/hooks/__tests__/formula-selection-business.spec.ts index 6e74f88516c9..82137e602384 100644 --- a/packages/sheets-formula-ui/src/views/formula-editor/hooks/__tests__/formula-selection-business.spec.ts +++ b/packages/sheets-formula-ui/src/views/formula-editor/hooks/__tests__/formula-selection-business.spec.ts @@ -14,11 +14,22 @@ * limitations under the License. */ +// @vitest-environment jsdom + +import { DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY, DOCS_NORMAL_EDITOR_UNIT_ID_KEY, toDisposable } from '@univerjs/core'; import { sequenceNodeType } from '@univerjs/engine-formula'; import { describe, expect, it, vi } from 'vitest'; -import { shouldSkipReferenceEditingByPointer } from '../use-formula-selection'; -import { buildTextRuns, calcHighlightRanges } from '../use-highlight'; -import { createSelectionChangeHandler, getSelectionsForFormulaRefUpdate } from '../use-sheet-selection-change'; +import { + FORMULA_EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE, + FORMULA_EMBED_RUNTIME_FOCUS_ROLE_ATTRIBUTE, + isEventTargetInSameFormulaEmbedInteractionBoundary, +} from '../../formula-embed-integration.service'; +import { registerFormulaEditorRuntimePortal } from '../..'; +import { focusFormulaEditor, hasActiveFormulaEmbedInteraction, shouldRefocusFormulaEditorOnMouseUp, shouldSkipFormulaEditorMouseUpFocus } from '../use-focus'; +import { FormulaSelectingType, resolveFormulaSelectingIntent, resolveFormulaSelectionCursorIndex, resolveFormulaSelectionDataStream, resolveFormulaSelectionWorkbook, shouldSkipReferenceEditingByPointer } from '../use-formula-selection'; +import { buildTextRuns, calcHighlightRanges, getFormulaHighlightDataStream } from '../use-highlight'; +import { isFormulaEditorInteractionOwner, shouldMoveFormulaSelectionFromCurrentSelection } from '../use-left-and-right-arrow'; +import { createSelectionChangeDuplicateEndGuard, createSelectionChangeHandler, getInitialFormulaReferenceSelectionCount, getLastFormulaSelection, getSelectionsForFormulaRefUpdate, getSequenceNodeCharAtOffset, getSharedSelectionChangeDuplicateEndGuard, insertFormulaReferenceText, isFormulaReferenceAddingContext, isFormulaReferenceAddingTextContext, isSameFormulaSelection, prepareSelectionChangeContext, replaceFormulaControlSelection, shouldSkipFormulaReferenceUpdate } from '../use-sheet-selection-change'; function range(row: number, col: number, sheetId = 'sheet1', unitId = 'unit1') { return { @@ -32,12 +43,219 @@ function range(row: number, col: number, sheetId = 'sheet1', unitId = 'unit1') { } describe('formula selection update helpers', () => { + it('does not rewrite editor selections while doc pointer selection is in progress', () => { + const editorService = { + focus: vi.fn(), + }; + const editor = { + getEditorId: vi.fn(() => 'editor-1'), + getSelectionRanges: vi.fn(() => [{ startOffset: 1, endOffset: 1, collapsed: true }]), + setSelectionRanges: vi.fn(), + getDocumentData: vi.fn(() => ({ body: { dataStream: 'abc\r\n' } })), + docSelectionRenderService: { + isOnPointerEvent: true, + }, + }; + + focusFormulaEditor(editorService as never, editor as never); + + expect(editorService.focus).toHaveBeenCalledWith('editor-1'); + expect(editor.setSelectionRanges).not.toHaveBeenCalled(); + }); + + it('keeps formula editor mouse-up refocus available when the editor canvas handled the pointer interaction', () => { + const canvas = document.createElement('canvas'); + canvas.dataset.uComp = 'render-canvas'; + const button = document.createElement('button'); + + expect(shouldSkipFormulaEditorMouseUpFocus(canvas)).toBe(false); + expect(shouldSkipFormulaEditorMouseUpFocus(button)).toBe(false); + }); + + it('does not refocus the formula editor on mouse-up when the editor is already focused', () => { + const canvas = document.createElement('canvas'); + canvas.dataset.uComp = 'render-canvas'; + + expect(shouldRefocusFormulaEditorOnMouseUp({ + target: canvas, + isFocusing: true, + isPointerSelecting: false, + })).toBe(false); + }); + + it('does not refocus the formula editor while pointer text selection is active', () => { + const canvas = document.createElement('canvas'); + canvas.dataset.uComp = 'render-canvas'; + + expect(shouldRefocusFormulaEditorOnMouseUp({ + target: canvas, + isFocusing: false, + isPointerSelecting: true, + })).toBe(false); + }); + + it('refocuses the formula editor on mouse-up only when focus was lost and no pointer selection is active', () => { + const canvas = document.createElement('canvas'); + canvas.dataset.uComp = 'render-canvas'; + + expect(shouldRefocusFormulaEditorOnMouseUp({ + target: canvas, + isFocusing: false, + isPointerSelecting: false, + })).toBe(true); + }); + + it('treats sheet range selection inside the same embed owner as an active formula interaction', () => { + const block = document.createElement('div'); + const editorHost = document.createElement('div'); + const sheetCanvas = document.createElement('canvas'); + block.setAttribute('data-embed-interaction-boundary-owner', 'embed-1'); + block.append(editorHost, sheetCanvas); + document.body.appendChild(block); + + sheetCanvas.tabIndex = -1; + sheetCanvas.focus(); + + expect(isEventTargetInSameFormulaEmbedInteractionBoundary(editorHost, sheetCanvas)).toBe(true); + expect(hasActiveFormulaEmbedInteraction(editorHost)).toBe(true); + + block.remove(); + }); + + it('registers formula editor portal elements as embedded child editors', () => { + const editorId = DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY; + const portalRoot = document.createElement('div'); + const editorElement = document.createElement('div'); + portalRoot.id = `univer-doc-selection-container-${editorId}`; + editorElement.id = `__editor_${editorId}`; + portalRoot.appendChild(editorElement); + document.body.appendChild(portalRoot); + + const registeredFocusElements = new Set(); + const registeredBoundaryElements = new Set(); + const focusCoordinator = { + resolveRuntimeScopeByChildUnitId: () => undefined, + acquireLease: () => toDisposable(() => {}), + registerElement: ({ element, role }: { element: HTMLElement; role: string }) => { + registeredFocusElements.add(element); + element.setAttribute(FORMULA_EMBED_RUNTIME_FOCUS_ROLE_ATTRIBUTE, role); + return toDisposable(() => { + registeredFocusElements.delete(element); + element.removeAttribute(FORMULA_EMBED_RUNTIME_FOCUS_ROLE_ATTRIBUTE); + }); + }, + }; + const interactionBoundaryService = { + registerOwnedElement: (embedId: string, element: Element) => { + registeredBoundaryElements.add(element); + element.setAttribute(FORMULA_EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE, embedId); + return toDisposable(() => { + registeredBoundaryElements.delete(element); + element.removeAttribute(FORMULA_EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE); + }); + }, + }; + const disposable = registerFormulaEditorRuntimePortal({ + embedId: 'embed-1', + editorId, + interactionBoundaryService, + focusCoordinator, + }); + + expect(portalRoot.getAttribute(FORMULA_EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE)).toBe('embed-1'); + expect(editorElement.getAttribute(FORMULA_EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE)).toBe('embed-1'); + expect(portalRoot.getAttribute(FORMULA_EMBED_RUNTIME_FOCUS_ROLE_ATTRIBUTE)).toBe('child-editor'); + expect(editorElement.getAttribute(FORMULA_EMBED_RUNTIME_FOCUS_ROLE_ATTRIBUTE)).toBe('child-editor'); + expect(registeredBoundaryElements.has(editorElement)).toBe(true); + expect(registeredFocusElements.has(editorElement)).toBe(true); + + disposable.dispose(); + + expect(portalRoot.hasAttribute(FORMULA_EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE)).toBe(false); + expect(editorElement.hasAttribute(FORMULA_EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE)).toBe(false); + portalRoot.remove(); + }); + it('only skips reference editing when pointer-origin editing is still disabled and click editing is allowed', () => { expect(shouldSkipReferenceEditingByPointer(true)).toBe(true); expect(shouldSkipReferenceEditingByPointer(true, true)).toBe(false); expect(shouldSkipReferenceEditingByPointer(false)).toBe(false); }); + it('falls back to the formula editor workbook when the focused current workbook is unavailable', () => { + const fallbackWorkbook = { unitId: 'embedded-sheet' }; + + expect(resolveFormulaSelectionWorkbook(undefined, fallbackWorkbook)).toBe(fallbackWorkbook); + expect(resolveFormulaSelectionWorkbook(null, fallbackWorkbook)).toBe(fallbackWorkbook); + expect(resolveFormulaSelectionWorkbook({ unitId: 'current-sheet' }, fallbackWorkbook)).toEqual({ unitId: 'current-sheet' }); + }); + + it('treats the hidden normal editor as the fx bar owner while the fx bar owns formula selection', () => { + expect(isFormulaEditorInteractionOwner(DOCS_NORMAL_EDITOR_UNIT_ID_KEY, DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY, { + fxBarFocused: true, + })).toBe(true); + }); + + it('does not let the hidden normal editor own the fx bar outside an fx formula selection session', () => { + expect(isFormulaEditorInteractionOwner(DOCS_NORMAL_EDITOR_UNIT_ID_KEY, DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY, { + fxBarFocused: false, + })).toBe(false); + }); + + it('reads formula selection text from the formula editor instead of the current host document', () => { + const accessor = { + get: vi.fn(() => ({ + getCurrentUniverDocInstance: vi.fn(() => ({ + getBody: () => ({ dataStream: 'host document text\r\n' }), + })), + })), + }; + const editor = { + getDocumentData: vi.fn(() => ({ + body: { + dataStream: '=SUM(\r\n', + }, + })), + }; + + expect(resolveFormulaSelectionDataStream(accessor as never, editor as never)).toEqual({ + dataStream: '=SUM(\r\n', + offset: 0, + }); + }); + + it('reads formula selection text from the editor unit before falling back to the current host document', () => { + const accessor = { + get: vi.fn(() => ({ + getUnit: vi.fn((unitId: string) => unitId === 'formula-editor' + ? { getBody: () => ({ dataStream: '=A1\r\n' }) } + : undefined), + getCurrentUniverDocInstance: vi.fn(() => ({ + getBody: () => ({ dataStream: 'host document text\r\n' }), + })), + })), + }; + + expect(resolveFormulaSelectionDataStream(accessor as never, undefined, 'formula-editor')).toEqual({ + dataStream: '=A1\r\n', + offset: 0, + }); + }); + + it('uses the end of a fresh formula when the editor selection offset is still stale', () => { + expect(resolveFormulaSelectionCursorIndex({ collapsed: true, startOffset: 0 }, '=')).toBe(1); + expect(resolveFormulaSelectionCursorIndex({ collapsed: true, startOffset: 0 }, '=SUM(')).toBe(5); + expect(resolveFormulaSelectionCursorIndex({ collapsed: true, startOffset: 0 }, 'plain')).toBe(0); + expect(resolveFormulaSelectionCursorIndex({ collapsed: true, startOffset: 2 }, '=A1')).toBe(2); + }); + + it('prefers adding a new formula reference when the cursor is after a delimiter', () => { + expect(resolveFormulaSelectingIntent(true, true)).toBe(FormulaSelectingType.NEED_ADD); + expect(resolveFormulaSelectingIntent(true, false)).toBe(FormulaSelectingType.NEED_ADD); + expect(resolveFormulaSelectingIntent(false, true)).toBe(FormulaSelectingType.CAN_EDIT); + expect(resolveFormulaSelectingIntent(false, false)).toBe(FormulaSelectingType.NOT_SELECT); + }); + it('reorders the active selection into the formula reference being edited and keeps ctrl-added ranges separate', () => { const selections = [range(0, 0), range(1, 1), range(2, 2)]; @@ -53,7 +271,7 @@ describe('formula selection update helpers', () => { }); }); - it('defers ctrl-add selection updates until move end and ignores initial selection events', () => { + it('previews ctrl-add selection updates before move end and ignores initial selection events', () => { const onSelectionsChange = vi.fn(); const handler = createSelectionChangeHandler({ initialSelectionsCount: 1, @@ -62,17 +280,303 @@ describe('formula selection update helpers', () => { handler([range(0, 0)], true, { initial: true }); handler([range(0, 0), range(1, 1)], false); - expect(onSelectionsChange).not.toHaveBeenCalled(); + expect(onSelectionsChange).toHaveBeenCalledWith([range(0, 0), range(1, 1)], false, true); handler([range(0, 0), range(1, 1)], true); - expect(onSelectionsChange).toHaveBeenCalledWith([range(0, 0), range(1, 1)], true, true); + expect(onSelectionsChange).toHaveBeenCalledTimes(1); handler([range(3, 3)], false); expect(onSelectionsChange).toHaveBeenLastCalledWith([range(3, 3)], false, false); }); + + it('ignores replayed initial formula references when no initial reference selection exists', () => { + const onSelectionsChange = vi.fn(); + const handler = createSelectionChangeHandler({ + initialSelectionsCount: 0, + onSelectionsChange, + }); + + handler([range(7, 5)], true, { initial: true }); + + expect(onSelectionsChange).not.toHaveBeenCalled(); + }); + + it('uses the formula text end as the insertion context when the embedded editor selection is temporarily empty', () => { + const lexerTreeBuilder = { + sequenceNodesBuilder: vi.fn(() => []), + }; + const editor = { + getSelectionRanges: vi.fn(() => []), + getDocumentData: vi.fn(() => ({ body: { dataStream: '=\r\n' } })), + }; + + expect(prepareSelectionChangeContext({ + editor: editor as never, + lexerTreeBuilder: lexerTreeBuilder as never, + })).toMatchObject({ + offset: 0, + nodeIndex: -1, + updatingRefIndex: -1, + sequenceNodes: [], + }); + }); + + it('does not create a fallback formula context for non-formula editor text', () => { + const lexerTreeBuilder = { + sequenceNodesBuilder: vi.fn(() => []), + }; + const editor = { + getSelectionRanges: vi.fn(() => []), + getDocumentData: vi.fn(() => ({ body: { dataStream: 'plain\r\n' } })), + }; + + expect(prepareSelectionChangeContext({ + editor: editor as never, + lexerTreeBuilder: lexerTreeBuilder as never, + })).toBeUndefined(); + }); + + it('starts the first keyboard-added formula reference from the edited cell selection', () => { + expect(shouldMoveFormulaSelectionFromCurrentSelection(FormulaSelectingType.NEED_ADD, 0)).toBe(true); + }); + + it('routes formula editor interactions only to the focused formula editor', () => { + expect(isFormulaEditorInteractionOwner('__INTERNAL_EDITOR__DOCS_NORMAL', '__INTERNAL_EDITOR__DOCS_NORMAL')).toBe(true); + expect(isFormulaEditorInteractionOwner('__INTERNAL_EDITOR__DOCS_FORMULA_BAR', '__INTERNAL_EDITOR__DOCS_NORMAL')).toBe(false); + expect(isFormulaEditorInteractionOwner(null, '__INTERNAL_EDITOR__DOCS_NORMAL')).toBe(false); + }); + + it('continues keyboard-added formula references from the last reference selection after a delimiter', () => { + expect(shouldMoveFormulaSelectionFromCurrentSelection(FormulaSelectingType.NEED_ADD, 1)).toBe(false); + expect(shouldMoveFormulaSelectionFromCurrentSelection(FormulaSelectingType.NEED_ADD, 2)).toBe(false); + }); + + it('keeps cross-sheet reference editing anchored to the current sheet selection', () => { + expect(shouldMoveFormulaSelectionFromCurrentSelection(FormulaSelectingType.EDIT_OTHER_SHEET_REFERENCE, 1)).toBe(true); + expect(shouldMoveFormulaSelectionFromCurrentSelection(FormulaSelectingType.CAN_EDIT, 1)).toBe(false); + }); + + it('recognizes delimiter-adjacent formula context as a new reference insertion point', () => { + const nodes = [ + { token: 'M28', nodeType: sequenceNodeType.REFERENCE }, + ',', + ]; + + expect(getSequenceNodeCharAtOffset(nodes, 3)).toBe('8'); + expect(getSequenceNodeCharAtOffset(nodes, 4)).toBe(','); + expect(isFormulaReferenceAddingContext(nodes, 3)).toBe(false); + expect(isFormulaReferenceAddingContext(nodes, 4)).toBe(true); + expect(isFormulaReferenceAddingTextContext('M28,', 4)).toBe(true); + expect(insertFormulaReferenceText('M28,', 'M27', 4)).toBe('M28,M27'); + }); + + it('skips stale non-add formula selection updates when no rendered reference exists', () => { + expect(shouldSkipFormulaReferenceUpdate(false, 0)).toBe(true); + expect(shouldSkipFormulaReferenceUpdate(false, 1)).toBe(false); + expect(shouldSkipFormulaReferenceUpdate(true, 0)).toBe(false); + }); + + it('applies a click-created formula reference from selection start before pointer-up controls are reset', () => { + const onSelectionsChange = vi.fn(); + const handler = createSelectionChangeHandler({ + initialSelectionsCount: 0, + onSelectionsChange, + }); + + handler([range(7, 5)], false); + handler([], true); + + expect(onSelectionsChange).toHaveBeenCalledWith([range(7, 5)], false, false); + expect(onSelectionsChange).toHaveBeenCalledTimes(1); + }); + + it('does not reapply the same click-created formula reference on selection end', () => { + const onSelectionsChange = vi.fn(); + const handler = createSelectionChangeHandler({ + initialSelectionsCount: 0, + onSelectionsChange, + }); + + handler([range(7, 5)], false); + handler([range(7, 5)], true); + + expect(onSelectionsChange).toHaveBeenCalledWith([range(7, 5)], false, false); + expect(onSelectionsChange).toHaveBeenCalledTimes(1); + }); + + it('commits duplicate selection end without reapplying formula text changes', () => { + const onSelectionsChange = vi.fn(); + const onDuplicateEnd = vi.fn(); + const handler = createSelectionChangeHandler({ + initialSelectionsCount: 0, + onSelectionsChange, + onDuplicateEnd, + }); + + handler([range(7, 5)], false); + handler([range(7, 5)], true); + + expect(onSelectionsChange).toHaveBeenCalledWith([range(7, 5)], false, false); + expect(onSelectionsChange).toHaveBeenCalledTimes(1); + expect(onDuplicateEnd).toHaveBeenCalledWith([range(7, 5)]); + expect(onDuplicateEnd).toHaveBeenCalledTimes(1); + }); + + it('does not reapply the same click-created formula reference while selection is still moving', () => { + const onSelectionsChange = vi.fn(); + const handler = createSelectionChangeHandler({ + initialSelectionsCount: 0, + onSelectionsChange, + }); + + handler([range(7, 5)], false); + handler([range(7, 5)], false); + + expect(onSelectionsChange).toHaveBeenCalledWith([range(7, 5)], false, false); + expect(onSelectionsChange).toHaveBeenCalledTimes(1); + }); + + it('previews a ctrl-added formula reference before selection end', () => { + const onSelectionsChange = vi.fn(); + const handler = createSelectionChangeHandler({ + initialSelectionsCount: 1, + onSelectionsChange, + }); + const existingRange = range(7, 5); + const addedRange = range(8, 6); + + handler([existingRange, addedRange], false); + + expect(onSelectionsChange).toHaveBeenCalledWith([existingRange, addedRange], false, true); + expect(onSelectionsChange).toHaveBeenCalledTimes(1); + }); + + it('updates a pending ctrl-added reference as the active reference while dragging', () => { + const onSelectionsChange = vi.fn(); + const handler = createSelectionChangeHandler({ + initialSelectionsCount: 1, + onSelectionsChange, + }); + const existingRange = range(7, 5); + const firstAddedRange = range(8, 6); + const movedAddedRange = { ...range(8, 6), endRow: 10, endColumn: 8 }; + + handler([existingRange, firstAddedRange], false); + handler([existingRange, movedAddedRange], false); + + expect(onSelectionsChange).toHaveBeenNthCalledWith(1, [existingRange, firstAddedRange], false, true); + expect(onSelectionsChange).toHaveBeenNthCalledWith(2, [existingRange, movedAddedRange], false, false); + expect(onSelectionsChange).toHaveBeenCalledTimes(2); + }); + + it('does not append duplicate ctrl-added previews for the same range', () => { + const onSelectionsChange = vi.fn(); + const handler = createSelectionChangeHandler({ + initialSelectionsCount: 1, + onSelectionsChange, + }); + const existingRange = range(7, 5); + const addedRange = range(8, 6); + + handler([existingRange, addedRange], false); + handler([existingRange, addedRange], false); + + expect(onSelectionsChange).toHaveBeenCalledWith([existingRange, addedRange], false, true); + expect(onSelectionsChange).toHaveBeenCalledTimes(1); + }); + + it('keeps ordinary same-count dragging in replace mode', () => { + const onSelectionsChange = vi.fn(); + const handler = createSelectionChangeHandler({ + initialSelectionsCount: 2, + onSelectionsChange, + }); + const existingRange = range(7, 5); + const movedRange = { ...range(8, 6), endRow: 10, endColumn: 8 }; + + handler([existingRange, movedRange], false); + + expect(onSelectionsChange).toHaveBeenCalledWith([existingRange, movedRange], false, false); + expect(onSelectionsChange).toHaveBeenCalledTimes(1); + }); + + it('dedupes the same formula reference when selection end is reported by another source', () => { + const duplicateEndGuard = createSelectionChangeDuplicateEndGuard(); + const onSelectionsChange = vi.fn(); + const handler = createSelectionChangeHandler({ + initialSelectionsCount: 0, + duplicateEndGuard, + onSelectionsChange, + }); + const selectedRange = range(7, 5); + const renderedRange = { ...selectedRange, startX: 10, endX: 20, startY: 30, endY: 40 }; + + handler([renderedRange], false); + if (!duplicateEndGuard.shouldSkip([selectedRange], true)) { + onSelectionsChange([selectedRange], true); + } + + expect(onSelectionsChange).toHaveBeenCalledWith([renderedRange], false, false); + expect(onSelectionsChange).toHaveBeenCalledTimes(1); + }); + + it('matches formula selections by range identity instead of render coordinates', () => { + expect(isSameFormulaSelection( + { ...range(7, 5), startX: 10, endX: 20 }, + range(7, 5, 'other-sheet', 'other-unit') + )).toBe(true); + expect(isSameFormulaSelection( + { ...range(7, 5), startX: 10, endX: 20 }, + range(7, 6) + )).toBe(false); + }); + + it('shares duplicate formula selection guards across paired formula editors', () => { + const guardFromCellEditor = getSharedSelectionChangeDuplicateEndGuard('unit1:sheet1:test'); + const guardFromFormulaBar = getSharedSelectionChangeDuplicateEndGuard('unit1:sheet1:test'); + + expect(guardFromCellEditor.shouldSkip([range(7, 5)], false)).toBe(false); + expect(guardFromFormulaBar.shouldSkip([range(7, 5)], false)).toBe(true); + + guardFromCellEditor.reset(); + }); + + it('counts only rendered formula reference controls and parsed formula references as initial references', () => { + expect(getInitialFormulaReferenceSelectionCount(0, 0)).toBe(0); + expect(getInitialFormulaReferenceSelectionCount(0, 2)).toBe(2); + expect(getInitialFormulaReferenceSelectionCount(1, 0)).toBe(1); + expect(getInitialFormulaReferenceSelectionCount(1, 0, FormulaSelectingType.NEED_ADD)).toBe(1); + expect(getInitialFormulaReferenceSelectionCount(1, 1, FormulaSelectingType.NEED_ADD)).toBe(1); + }); + + it('keeps formula control ranges scoped to the current selection unit and sheet', () => { + const selections = [range(0, 0, 'sheet1', 'unit1')]; + + expect(replaceFormulaControlSelection(selections, 0, range(2, 2, 'other-sheet', 'other-unit'))).toEqual([ + range(2, 2, 'sheet1', 'unit1'), + ]); + }); + + it('ignores stale formula control events when the matching selection data is unavailable', () => { + expect(replaceFormulaControlSelection([], 0, range(2, 2))).toBeUndefined(); + }); + + it('returns no formula selection for empty selection events', () => { + expect(getLastFormulaSelection([])).toBeUndefined(); + expect(getLastFormulaSelection([range(1, 1), range(2, 2)])).toEqual(range(2, 2)); + }); }); describe('formula highlight helpers', () => { + it('preserves incomplete formula editor text while applying token highlights', () => { + expect(getFormulaHighlightDataStream('=', [ + 'SUM(', + { token: 'D37', nodeType: sequenceNodeType.REFERENCE, startIndex: 4, endIndex: 6 }, + ',', + { token: 'F40', nodeType: sequenceNodeType.REFERENCE, startIndex: 8, endIndex: 10 }, + ], 'SUM(D37,F40,J36,J42')).toBe('=SUM(D37,F40,J36,J42\r\n'); + }); + it('builds colored text runs for references, numbers, strings, arrays, defined names, and plain text', () => { const result = buildTextRuns( { hasDefinedNameDescription: vi.fn((token: string) => token === 'SalesTotal') } as any, @@ -246,7 +750,7 @@ describe('formula highlight helpers', () => { expect(result?.[0].primary).toBeUndefined(); }); - it('clears highlight selections when the workbook or active sheet is unavailable', () => { + it('returns empty highlight selections when the workbook or active sheet is unavailable', () => { const refSelectionsService = { getCurrentSelections: vi.fn(() => []), setSelections: vi.fn(), @@ -263,7 +767,7 @@ describe('formula highlight helpers', () => { sheetSkeletonManagerService: undefined, themeService: { getColorFromTheme: vi.fn(() => '#fff') } as any, univerInstanceService: { getUnit: vi.fn(() => null) } as any, - })).toBeUndefined(); - expect(refSelectionsService.setSelections).toHaveBeenCalledWith([]); + })).toEqual([]); + expect(refSelectionsService.setSelections).not.toHaveBeenCalled(); }); }); diff --git a/packages/sheets-formula-ui/src/views/formula-editor/hooks/use-focus.ts b/packages/sheets-formula-ui/src/views/formula-editor/hooks/use-focus.ts index 917cfc0a2a0f..ffe671067ad8 100644 --- a/packages/sheets-formula-ui/src/views/formula-editor/hooks/use-focus.ts +++ b/packages/sheets-formula-ui/src/views/formula-editor/hooks/use-focus.ts @@ -18,23 +18,70 @@ import type { Editor } from '@univerjs/docs-ui'; import { Tools } from '@univerjs/core'; import { IEditorService } from '@univerjs/docs-ui'; import { useDependency, useEvent } from '@univerjs/ui'; +import { isEventTargetInSameFormulaEmbedInteractionBoundary } from '../formula-embed-integration.service'; + +export function focusFormulaEditor( + editorService: Pick, + editor?: Pick & { editorDOM?: HTMLElement }, + offset?: number +) { + if (!editor) { + return; + } + + editorService.focus(editor.getEditorId()); + focusFormulaEditorElement(editor); + if (editor.docSelectionRenderService.isOnPointerEvent) { + return; + } + + const selections = [...editor.getSelectionRanges()]; + if (Tools.isDefine(offset)) { + editor.setSelectionRanges([{ startOffset: offset, endOffset: offset }]); + } else if (!selections.length) { + const body = editor.getDocumentData().body?.dataStream ?? '\r\n'; + const offset = Math.max(body.length - 2, 0); + editor.setSelectionRanges([{ startOffset: offset, endOffset: offset }]); + } else { + editor.setSelectionRanges(selections); + } +} + +function focusFormulaEditorElement(editor: Pick & { editorDOM?: HTMLElement }): void { + const ownerDocument = editor.editorDOM?.ownerDocument ?? document; + const editorElement = ownerDocument.getElementById(`__editor_${editor.getEditorId()}`); + editorElement?.focus({ preventScroll: true }); +} + +export function shouldSkipFormulaEditorMouseUpFocus(_target: EventTarget | null): boolean { + return false; +} + +export function shouldRefocusFormulaEditorOnMouseUp(options: { + target: EventTarget | null; + isFocusing: boolean | undefined; + isPointerSelecting: boolean | undefined; +}): boolean { + if (shouldSkipFormulaEditorMouseUpFocus(options.target)) { + return false; + } + + if (options.isPointerSelecting || options.isFocusing) { + return false; + } + + return true; +} + +export function hasActiveFormulaEmbedInteraction(scopeElement: HTMLElement | null | undefined): boolean { + const ownerDocument = scopeElement?.ownerDocument; + return isEventTargetInSameFormulaEmbedInteractionBoundary(scopeElement, ownerDocument?.activeElement); +} export const useFocus = (editor?: Editor) => { const editorService = useDependency(IEditorService); const focus = useEvent((offset?: number) => { - if (editor) { - editorService.focus(editor.getEditorId()); - const selections = [...editor.getSelectionRanges()]; - if (Tools.isDefine(offset)) { - editor.setSelectionRanges([{ startOffset: offset, endOffset: offset }]); - } else if (!selections.length && !editor.docSelectionRenderService.isOnPointerEvent) { - const body = editor.getDocumentData().body?.dataStream ?? '\r\n'; - const offset = Math.max(body.length - 2, 0); - editor.setSelectionRanges([{ startOffset: offset, endOffset: offset }]); - } else { - editor.setSelectionRanges(selections); - } - }; + focusFormulaEditor(editorService, editor, offset); }); return focus; diff --git a/packages/sheets-formula-ui/src/views/formula-editor/hooks/use-formula-selection.ts b/packages/sheets-formula-ui/src/views/formula-editor/hooks/use-formula-selection.ts index e0c79b082c69..b045add154e0 100644 --- a/packages/sheets-formula-ui/src/views/formula-editor/hooks/use-formula-selection.ts +++ b/packages/sheets-formula-ui/src/views/formula-editor/hooks/use-formula-selection.ts @@ -15,6 +15,7 @@ */ import type { DocumentDataModel, IAccessor, IUnitRangeName, Workbook } from '@univerjs/core'; +import type { Editor } from '@univerjs/docs-ui'; import type { ISequenceNode } from '@univerjs/engine-formula'; import { Injector, IUniverInstanceService, UniverInstanceType } from '@univerjs/core'; import { DocSelectionManagerService } from '@univerjs/docs'; @@ -27,9 +28,17 @@ import { filter } from 'rxjs'; import { RefSelectionsRenderService } from '../../../services/render-services/ref-selections.render.service'; import { useStateRef } from './use-state-ref'; -function getCurrentBodyDataStreamAndOffset(accssor: IAccessor) { +export function resolveFormulaSelectionDataStream(accssor: IAccessor, editor?: Pick, editorId?: string) { + const editorDataStream = editor?.getDocumentData().body?.dataStream; + if (editorDataStream != null) { + return { dataStream: editorDataStream, offset: 0 }; + } + const univerInstanceService = accssor.get(IUniverInstanceService); - const documentModel = univerInstanceService.getCurrentUnitOfType(UniverInstanceType.UNIVER_DOC); + const editorDocumentModel = editorId + ? univerInstanceService.getUnit(editorId, UniverInstanceType.UNIVER_DOC) + : undefined; + const documentModel = editorDocumentModel ?? univerInstanceService.getCurrentUnitOfType(UniverInstanceType.UNIVER_DOC); if (!documentModel?.getBody()) { return; @@ -52,9 +61,69 @@ export function shouldSkipReferenceEditingByPointer(isDisabledByPointer: boolean return isDisabledByPointer && !disableOnClick; } +export function resolveFormulaSelectionWorkbook(currentWorkbook: TWorkbook | null | undefined, fallbackWorkbook: TWorkbook | null | undefined): TWorkbook | undefined { + return currentWorkbook ?? fallbackWorkbook ?? undefined; +} + +export function resolveFormulaSelectionCursorIndex(activeRange: { collapsed?: boolean; startOffset?: number } | undefined, dataStream: string): number { + const index = activeRange?.collapsed ? activeRange.startOffset! : -1; + if (index <= 0 && dataStream.startsWith('=') && dataStream.length > 0) { + return dataStream.length; + } + + return index; +} + +export function getSelectionAfterLaggingFormulaInput( + dataStream: string, + selection: { collapsed?: boolean; startOffset?: number; endOffset?: number } | undefined, + content: string +): { startOffset: number; endOffset: number; collapsed: true } | undefined { + if ( + !content || + content.includes('\r') || + content.includes('\n') || + !dataStream.startsWith('=') || + !selection?.collapsed + ) { + return undefined; + } + + const startOffset = selection.startOffset; + if (startOffset == null) { + return undefined; + } + if (selection.endOffset !== startOffset) { + return undefined; + } + + if (dataStream.slice(startOffset, startOffset + content.length) !== content) { + return undefined; + } + + const nextOffset = startOffset + content.length; + return { + startOffset: nextOffset, + endOffset: nextOffset, + collapsed: true, + }; +} + +export function resolveFormulaSelectingIntent(adding: boolean, editing: boolean): FormulaSelectingType { + if (adding) { + return FormulaSelectingType.NEED_ADD; + } + + if (editing) { + return FormulaSelectingType.CAN_EDIT; + } + + return FormulaSelectingType.NOT_SELECT; +} + // eslint-disable-next-line max-lines-per-function -export function useFormulaSelecting(opts: { editorId: string; isFocus: boolean; disableOnClick?: boolean; unitId: string; subUnitId: string }) { - const { editorId, isFocus, disableOnClick, unitId, subUnitId } = opts; +export function useFormulaSelecting(opts: { editor?: Editor; editorId: string; isFocus: boolean; disableOnClick?: boolean; unitId: string; subUnitId: string }) { + const { editor, editorId, isFocus, disableOnClick, unitId, subUnitId } = opts; const renderManagerService = useDependency(IRenderManagerService); const univerInstanceService = useDependency(IUniverInstanceService); const sheetRenderer = renderManagerService.getRenderById(unitId); @@ -65,6 +134,7 @@ export function useFormulaSelecting(opts: { editorId: string; isFocus: boolean; const [isSelecting, innerSetIsSelecting] = useState(FormulaSelectingType.NOT_SELECT); const lexerTreeBuilder = useDependency(LexerTreeBuilder); const isDisabledByPointer = useRef(true); + const lastInputContentRef = useRef(''); const refSelectionsRenderService = sheetRenderer?.with(RefSelectionsRenderService); const isSelectingRef = useStateRef(isSelecting); const workbook = univerInstanceService.getUnit(unitId, UniverInstanceType.UNIVER_SHEET); @@ -80,14 +150,18 @@ export function useFormulaSelecting(opts: { editorId: string; isFocus: boolean; // eslint-disable-next-line complexity const calculateSelectingType = useEvent(() => { - const currentWorkbook = univerInstanceService.getCurrentUnitOfType(UniverInstanceType.UNIVER_SHEET); + const currentWorkbook = resolveFormulaSelectionWorkbook( + univerInstanceService.getCurrentUnitOfType(UniverInstanceType.UNIVER_SHEET), + workbook + ); if (!currentWorkbook) return; const currentSheet = currentWorkbook.getActiveSheet(); - const activeRange = docSelectionRenderService?.getActiveTextRange(); - const index = activeRange?.collapsed ? activeRange.startOffset! : -1; - const config = getCurrentBodyDataStreamAndOffset(injector); + const activeRange = editor?.getSelectionRanges()?.[0] ?? docSelectionRenderService?.getActiveTextRange(); + const config = resolveFormulaSelectionDataStream(injector, editor, editorId); if (!config) return; const dataStream = config?.dataStream?.slice(0, -2); + const normalizedActiveRange = getSelectionAfterLaggingFormulaInput(dataStream, activeRange, lastInputContentRef.current) ?? activeRange; + const index = resolveFormulaSelectionCursorIndex(normalizedActiveRange, dataStream); const nodes = (lexerTreeBuilder.sequenceNodesBuilder(dataStream) ?? []).map((node) => { if (typeof node === 'object') { if (node.nodeType === sequenceNodeType.REFERENCE) { @@ -110,16 +184,23 @@ export function useFormulaSelecting(opts: { editorId: string; isFocus: boolean; const focusingNode = nodes.find((node) => typeof node === 'object' && node.nodeType === sequenceNodeType.REFERENCE && index === node.endIndex + 2) as unknown as (ISequenceNode & { range: IUnitRangeName }); const adding = (char && matchRefDrawToken(char)) && (!nextChar || (isFormulaLexerToken(nextChar) && nextChar !== matchToken.OPEN_BRACKET)); const editing = Boolean(focusingNode); + const selectingIntent = resolveFormulaSelectingIntent(Boolean(adding), editing); - if (dataStream?.substring(0, 1) === '=' && (adding || editing)) { - if (editing) { + if (dataStream?.substring(0, 1) === '=' && selectingIntent !== FormulaSelectingType.NOT_SELECT) { + if (selectingIntent === FormulaSelectingType.NEED_ADD) { + isDisabledByPointer.current = false; + setIsSelecting(FormulaSelectingType.NEED_ADD); + } else if (focusingNode) { if (shouldSkipReferenceEditingByPointer(isDisabledByPointer.current, disableOnClick)) { return; } isDisabledByPointer.current = false; const { sheetName, unitId } = focusingNode.range; - const currentUnitId = univerInstanceService.getCurrentUnitOfType(UniverInstanceType.UNIVER_SHEET)?.getUnitId(); + const currentUnitId = resolveFormulaSelectionWorkbook( + univerInstanceService.getCurrentUnitOfType(UniverInstanceType.UNIVER_SHEET), + workbook + )?.getUnitId(); if (unitId && unitId !== currentUnitId) { setIsSelecting(FormulaSelectingType.EDIT_OTHER_WORKBOOK_REFERENCE); } else if ( @@ -130,9 +211,6 @@ export function useFormulaSelecting(opts: { editorId: string; isFocus: boolean; } else { setIsSelecting(FormulaSelectingType.EDIT_OTHER_SHEET_REFERENCE); } - } else { - isDisabledByPointer.current = false; - setIsSelecting(FormulaSelectingType.NEED_ADD); } } else { setIsSelecting(FormulaSelectingType.NOT_SELECT); @@ -149,6 +227,50 @@ export function useFormulaSelecting(opts: { editorId: string; isFocus: boolean; return () => sub.unsubscribe(); }, [calculateSelectingType, docSelectionManagerService.textSelection$, editorId]); + useEffect(() => { + if (!isFocus || !editor) { + return; + } + + let timeout: ReturnType | undefined; + const sub = editor.input$.subscribe(({ content, isComposing }) => { + if (!isComposing) { + lastInputContentRef.current = content; + } + queueMicrotask(() => { + calculateSelectingType(); + }); + + if (timeout) { + clearTimeout(timeout); + } + timeout = setTimeout(() => { + calculateSelectingType(); + lastInputContentRef.current = ''; + }, 0); + }); + + return () => { + if (timeout) { + clearTimeout(timeout); + } + sub.unsubscribe(); + }; + }, [calculateSelectingType, editor, isFocus]); + + useEffect(() => { + if (!isFocus) { + return; + } + + const editorDocumentModel = univerInstanceService.getUnit(editorId, UniverInstanceType.UNIVER_DOC); + const sub = editorDocumentModel?.change$?.subscribe(() => { + queueMicrotask(calculateSelectingType); + }); + + return () => sub?.unsubscribe(); + }, [calculateSelectingType, editorId, isFocus, univerInstanceService]); + useEffect(() => { if (!isFocus) { setIsSelecting(FormulaSelectingType.NOT_SELECT); diff --git a/packages/sheets-formula-ui/src/views/formula-editor/hooks/use-highlight.ts b/packages/sheets-formula-ui/src/views/formula-editor/hooks/use-highlight.ts index 4b41c58f17be..fb9a8ee34c65 100644 --- a/packages/sheets-formula-ui/src/views/formula-editor/hooks/use-highlight.ts +++ b/packages/sheets-formula-ui/src/views/formula-editor/hooks/use-highlight.ts @@ -25,8 +25,8 @@ import { deserializeRangeWithSheet, sequenceNodeType } from '@univerjs/engine-fo import { IRenderManagerService } from '@univerjs/engine-render'; import { IRefSelectionsService, setEndForRange } from '@univerjs/sheets'; import { IDescriptionService } from '@univerjs/sheets-formula'; -import { SheetSkeletonManagerService } from '@univerjs/sheets-ui'; -import { useDependency, useEvent, useObservable } from '@univerjs/ui'; +import { ISheetSelectionRenderService, SheetSkeletonManagerService } from '@univerjs/sheets-ui'; +import { useDependency, useEvent } from '@univerjs/ui'; import { useEffect, useMemo } from 'react'; import { genFormulaRefSelectionStyle } from '../../../common/selection'; import { RefSelectionsRenderService } from '../../../services/render-services/ref-selections.render.service'; @@ -80,8 +80,7 @@ export function calcHighlightRanges(opts: { const worksheet = workbook?.getActiveSheet(); const selectionWithStyle: ISelectionWithStyle[] = []; if (!workbook || !worksheet) { - refSelectionsService.setSelections(selectionWithStyle); - return; + return selectionWithStyle; } const currentSheetId = worksheet.getSheetId(); const getSheetIdByName = (name: string) => workbook?.getSheetBySheetName(name)?.getSheetId(); @@ -148,6 +147,8 @@ export function calcHighlightRanges(opts: { const activeIndex = endIndexes.findIndex((end) => end + 2 === cursor); if (activeIndex !== -1) { refSelectionsRenderService?.setActiveSelectionIndex(activeIndex); + } else if (selectionWithStyle.length) { + refSelectionsRenderService?.setActiveSelectionIndex(selectionWithStyle.length - 1); } else { refSelectionsRenderService?.resetActiveSelectionIndex(); } @@ -167,15 +168,30 @@ export function useSheetHighlight(unitId: string, subUnitId: string) { const themeService = useDependency(ThemeService); const refSelectionsService = useDependency(IRefSelectionsService); const renderManagerService = useDependency(IRenderManagerService); - const currentWorkbook = useObservable(useMemo(() => univerInstanceService.getCurrentTypeOfUnit$(UniverInstanceType.UNIVER_SHEET), [univerInstanceService])); - const currentRender = currentWorkbook ? renderManagerService.getRenderById(currentWorkbook.getUnitId()) : null; - const refSelectionsRenderService = currentRender?.with(RefSelectionsRenderService); - const sheetSkeletonManagerService = currentRender?.with(SheetSkeletonManagerService); + const ownerRender = renderManagerService.getRenderById(unitId); + const ownerRefSelectionsRenderService = ownerRender?.with(RefSelectionsRenderService); - const highlightSheet = useEvent((refSelections: IRefSelection[], editor?: Editor) => { + const getHighlightWorkbook = useEvent((refSelections: IRefSelection[]) => { + const ownerWorkbook = univerInstanceService.getUnit(unitId, UniverInstanceType.UNIVER_SHEET); const currentWorkbook = univerInstanceService.getCurrentUnitOfType(UniverInstanceType.UNIVER_SHEET); + const currentUnitId = currentWorkbook?.getUnitId(); + const hasExplicitCurrentWorkbookRef = Boolean(currentUnitId) && refSelections.some((refSelection) => + deserializeRangeWithSheet(refSelection.token).unitId === currentUnitId + ); + + return hasExplicitCurrentWorkbookRef ? currentWorkbook : ownerWorkbook ?? currentWorkbook; + }); + + const highlightSheet = useEvent((refSelections: IRefSelection[], editor?: Editor, isEnd = false) => { + const currentWorkbook = getHighlightWorkbook(refSelections); if (!currentWorkbook) return; - if (refSelectionsRenderService?.selectionMoving) return; + const currentRender = renderManagerService.getRenderById(currentWorkbook.getUnitId()); + const refSelectionsRenderService = currentRender?.with(RefSelectionsRenderService); + const sheetSelectionRenderService = currentRender?.with(ISheetSelectionRenderService); + const sheetSkeletonManagerService = currentRender?.with(SheetSkeletonManagerService); + if (!isEnd && refSelectionsRenderService?.selectionMoving) return; + const currentSheetId = currentWorkbook.getActiveSheet()?.getSheetId(); + if (!currentSheetId) return; const selectionWithStyle = calcHighlightRanges({ unitId, subUnitId, @@ -195,15 +211,18 @@ export function useSheetHighlight(unitId: string, subUnitId: string) { if (allControls.length === selectionWithStyle.length) { refSelectionsRenderService?.resetSelectionsByModelData(selectionWithStyle); } else { - refSelectionsService.setSelections(selectionWithStyle); + refSelectionsService.setSelections(currentWorkbook.getUnitId(), currentSheetId, selectionWithStyle); + } + if (isEnd && selectionWithStyle.length) { + sheetSelectionRenderService?.resetSelectionsByModelData([]); } }); useEffect(() => { return () => { - refSelectionsRenderService?.resetActiveSelectionIndex(); + ownerRefSelectionsRenderService?.resetActiveSelectionIndex(); }; - }, [refSelectionsRenderService]); + }, [ownerRefSelectionsRenderService]); return highlightSheet; } @@ -218,7 +237,8 @@ export function useDocHight(_leadingCharacter: string = '') { editor: Editor, sequenceNodes: INode[], isNeedResetSelection = true, - newSelections?: ITextRange[] + newSelections?: ITextRange[], + sourceText?: string ) => { const data = editor.getDocumentData(); const editorId = editor.getEditorId(); @@ -249,13 +269,7 @@ export function useDocHight(_leadingCharacter: string = '') { } cloneBody.textRuns = [{ st: 0, ed: 1, ts: { fs: 11 } }, ...textRuns]; - const text = sequenceNodes.reduce((pre, cur) => { - if (typeof cur === 'string') { - return `${pre}${cur}`; - } - return `${pre}${cur.token}`; - }, ''); - cloneBody.dataStream = `${_leadingCharacter}${text}\r\n`; + cloneBody.dataStream = getFormulaHighlightDataStream(_leadingCharacter, sequenceNodes, sourceText); let selections; if (isNeedResetSelection) { // Switching between uppercase and lowercase will trigger a reflow, causing the cursor to be misplaced. Let's refresh the cursor position here. @@ -278,6 +292,17 @@ export function useDocHight(_leadingCharacter: string = '') { return highlightDoc; } +export function getFormulaHighlightDataStream(leadingCharacter: string, sequenceNodes: Array, sourceText?: string): string { + const text = sourceText ?? sequenceNodes.reduce((pre, cur) => { + if (typeof cur === 'string') { + return `${pre}${cur}`; + } + return `${pre}${cur.token}`; + }, ''); + + return `${leadingCharacter}${text}\r\n`; +} + interface IColorMap { formulaRefColors: string[]; numberColor: string; diff --git a/packages/sheets-formula-ui/src/views/formula-editor/hooks/use-left-and-right-arrow.ts b/packages/sheets-formula-ui/src/views/formula-editor/hooks/use-left-and-right-arrow.ts index de747ec53649..08853cf8b610 100644 --- a/packages/sheets-formula-ui/src/views/formula-editor/hooks/use-left-and-right-arrow.ts +++ b/packages/sheets-formula-ui/src/views/formula-editor/hooks/use-left-and-right-arrow.ts @@ -15,22 +15,65 @@ */ import type { Editor } from '@univerjs/docs-ui'; -import { CommandType, Direction, DisposableCollection, ICommandService } from '@univerjs/core'; -import { MoveCursorOperation, MoveSelectionOperation } from '@univerjs/docs-ui'; +import { CommandType, Direction, DisposableCollection, DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY, DOCS_NORMAL_EDITOR_UNIT_ID_KEY, FOCUSING_FX_BAR_EDITOR, generateRandomId, ICommandService, IContextService } from '@univerjs/core'; +import { IEditorService, MoveCursorOperation, MoveSelectionOperation } from '@univerjs/docs-ui'; import { DeviceInputEventType } from '@univerjs/engine-render'; import { ExpandSelectionCommand, JumpOver, MoveSelectionCommand } from '@univerjs/sheets-ui'; import { IShortcutService, KeyCode, MetaKeys, useDependency } from '@univerjs/ui'; -import { useEffect, useRef } from 'react'; +import { useEffect, useMemo, useRef } from 'react'; import { FormulaSelectingType } from './use-formula-selection'; -// eslint-disable-next-line max-lines-per-function -export const useLeftAndRightArrow = (isNeed: boolean, shouldMoveSelection: FormulaSelectingType, editor?: Editor, onMoveInEditor?: (keyCode: KeyCode, metaKey?: MetaKeys) => void) => { +export function shouldMoveFormulaSelectionFromCurrentSelection(selectingType: FormulaSelectingType, refSelectionCount: number): boolean { + if (selectingType === FormulaSelectingType.NEED_ADD) { + return refSelectionCount === 0; + } + + return selectingType === FormulaSelectingType.EDIT_OTHER_SHEET_REFERENCE; +} + +export interface IFormulaEditorInteractionOwnerOptions { + fxBarFocused?: boolean; + formulaBarEditorId?: string; + normalEditorId?: string; +} + +export function isFormulaEditorInteractionOwner( + focusEditorId: string | null | undefined | void, + editorId: string, + options?: IFormulaEditorInteractionOwnerOptions +): boolean { + if (focusEditorId === editorId) { + return true; + } + + if (!options?.fxBarFocused) { + return false; + } + + return editorId === (options.formulaBarEditorId ?? DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY) && + focusEditorId === (options.normalEditorId ?? DOCS_NORMAL_EDITOR_UNIT_ID_KEY); +} + +export const isFormulaEditorKeyboardOwner = isFormulaEditorInteractionOwner; + +export const useLeftAndRightArrow = ( + isNeed: boolean, + shouldMoveSelection: FormulaSelectingType, + editor?: Editor, + onMoveInEditor?: (keyCode: KeyCode, metaKey?: MetaKeys) => void, + getRefSelectionCount?: () => number +) => { const commandService = useDependency(ICommandService); const shortcutService = useDependency(IShortcutService); + const editorService = useDependency(IEditorService); + const contextService = useDependency(IContextService); + const operationNamespace = useMemo(() => generateRandomId(4), []); const shouldMoveSelectionRef = useRef(shouldMoveSelection); shouldMoveSelectionRef.current = shouldMoveSelection; const onMoveInEditorRef = useRef(onMoveInEditor); onMoveInEditorRef.current = onMoveInEditor; + const getRefSelectionCountRef = useRef(getRefSelectionCount); + getRefSelectionCountRef.current = getRefSelectionCount; // eslint-disable-next-line max-lines-per-function useEffect(() => { @@ -38,8 +81,17 @@ export const useLeftAndRightArrow = (isNeed: boolean, shouldMoveSelection: Formu return; } const editorId = editor.getEditorId(); - const operationId = `sheet.formula-embedding-editor.${editorId}`; + const operationId = `sheet.formula-embedding-editor.${editorId}.${operationNamespace}`; const d = new DisposableCollection(); + const shouldHandleShortcut = () => { + try { + const fxBarFocused = contextService.getContextValue(FOCUSING_FX_BAR_EDITOR); + return isFormulaEditorInteractionOwner(editorService.getFocusId(), editorId, { fxBarFocused }) && + (editor.docSelectionRenderService.isFocusing || fxBarFocused); + } catch { + return false; + } + }; const handleMoveInEditor = (keycode: KeyCode, metaKey?: MetaKeys) => { if (onMoveInEditorRef.current) { onMoveInEditorRef.current(keycode, metaKey); @@ -78,12 +130,16 @@ export const useLeftAndRightArrow = (isNeed: boolean, shouldMoveSelection: Formu direction = Direction.RIGHT; } if (shouldMoveSelectionRef.current) { + const fromCurrentSelection = shouldMoveFormulaSelectionFromCurrentSelection( + shouldMoveSelectionRef.current, + getRefSelectionCountRef.current?.() ?? 0 + ); if (metaKey === MetaKeys.CTRL_COMMAND) { commandService.executeCommand(MoveSelectionCommand.id, { direction, jumpOver: JumpOver.moveGap, extra: 'formula-editor', - fromCurrentSelection: shouldMoveSelectionRef.current === FormulaSelectingType.NEED_ADD || shouldMoveSelectionRef.current === FormulaSelectingType.EDIT_OTHER_SHEET_REFERENCE, + fromCurrentSelection, }); } else if (metaKey === MetaKeys.SHIFT) { commandService.executeCommand(ExpandSelectionCommand.id, { @@ -100,7 +156,7 @@ export const useLeftAndRightArrow = (isNeed: boolean, shouldMoveSelection: Formu commandService.executeCommand(MoveSelectionCommand.id, { direction, extra: 'formula-editor', - fromCurrentSelection: shouldMoveSelectionRef.current === FormulaSelectingType.NEED_ADD || shouldMoveSelectionRef.current === FormulaSelectingType.EDIT_OTHER_SHEET_REFERENCE, + fromCurrentSelection, }); } } else { @@ -140,7 +196,7 @@ export const useLeftAndRightArrow = (isNeed: boolean, shouldMoveSelection: Formu return { id: operationId, binding: metaKey ? keyCode | metaKey : keyCode, - preconditions: () => true, + preconditions: shouldHandleShortcut, priority: 900, staticParameters: { eventType: DeviceInputEventType.Keyboard, @@ -155,5 +211,5 @@ export const useLeftAndRightArrow = (isNeed: boolean, shouldMoveSelection: Formu return () => { d.dispose(); }; - }, [commandService, editor, isNeed, shortcutService]); + }, [commandService, contextService, editor, editorService, isNeed, operationNamespace, shortcutService]); }; diff --git a/packages/sheets-formula-ui/src/views/formula-editor/hooks/use-refactor-effect.ts b/packages/sheets-formula-ui/src/views/formula-editor/hooks/use-refactor-effect.ts index 7beca8328ec6..6579e4a04590 100644 --- a/packages/sheets-formula-ui/src/views/formula-editor/hooks/use-refactor-effect.ts +++ b/packages/sheets-formula-ui/src/views/formula-editor/hooks/use-refactor-effect.ts @@ -22,7 +22,7 @@ import { IContextMenuService, useDependency, useObservable } from '@univerjs/ui' import { useEffect, useLayoutEffect, useMemo } from 'react'; import { RefSelectionsRenderService } from '../../../services/render-services/ref-selections.render.service'; -export const useRefactorEffect = (isNeed: boolean, selecting: boolean, unitId: string, editorId: string, disableContextMenu = true) => { +export const useRefactorEffect = (isNeed: boolean, selecting: boolean | number, unitId: string, editorId: string, disableContextMenu = true) => { const renderManagerService = useDependency(IRenderManagerService); const contextService = useDependency(IContextService); const contextMenuService = useDependency(IContextMenuService); @@ -50,7 +50,7 @@ export const useRefactorEffect = (isNeed: boolean, selecting: boolean, unitId: s }, [contextService, isNeed, refSelectionsService, disableContextMenu, editorId]); useLayoutEffect(() => { - if (isNeed && selecting) { + if (isNeed && Boolean(selecting)) { const d1 = refSelectionsRenderService?.enableSelectionChanging(); contextService.setContextValue(REF_SELECTIONS_ENABLED, true); diff --git a/packages/sheets-formula-ui/src/views/formula-editor/hooks/use-sheet-selection-change.ts b/packages/sheets-formula-ui/src/views/formula-editor/hooks/use-sheet-selection-change.ts index cb086107db16..2979409d6bae 100644 --- a/packages/sheets-formula-ui/src/views/formula-editor/hooks/use-sheet-selection-change.ts +++ b/packages/sheets-formula-ui/src/views/formula-editor/hooks/use-sheet-selection-change.ts @@ -23,7 +23,9 @@ import type { RefObject } from 'react'; import type { IRefSelection } from './use-highlight'; import { DisposableCollection, + FOCUSING_FX_BAR_EDITOR, ICommandService, + IContextService, IUniverInstanceService, noop, Rectangle, @@ -31,10 +33,14 @@ import { UniverInstanceType, } from '@univerjs/core'; import { DocSelectionManagerService } from '@univerjs/docs'; +import { IEditorService } from '@univerjs/docs-ui'; import { deserializeRangeWithSheet, generateStringWithSequence, + isFormulaLexerToken, LexerTreeBuilder, + matchRefDrawToken, + matchToken, sequenceNodeType, serializeRange, serializeRangeWithSheet, @@ -52,30 +58,70 @@ import { findIndexFromSequenceNodes, findRefSequenceIndex } from '../../range-se import { getOffsetFromSequenceNodes } from '../../range-selector/utils/get-offset-from-sequence-nodes'; import { sequenceNodeToText } from '../../range-selector/utils/sequence-node-to-text'; import { unitRangesToText } from '../../range-selector/utils/unit-ranges-to-text'; -import { FormulaSelectingType } from './use-formula-selection'; +import { FormulaSelectingType, resolveFormulaSelectionWorkbook } from './use-formula-selection'; import { calcHighlightRanges } from './use-highlight'; +import { isFormulaEditorInteractionOwner } from './use-left-and-right-arrow'; import { useStateRef } from './use-state-ref'; -const prepareSelectionChangeContext = (opts: { editor?: Editor; lexerTreeBuilder: LexerTreeBuilder }) => { +export const prepareSelectionChangeContext = (opts: { editor?: Editor; lexerTreeBuilder: LexerTreeBuilder }) => { const { editor, lexerTreeBuilder } = opts; const currentDocSelections = editor?.getSelectionRanges(); - if (currentDocSelections?.length !== 1) { - return; - } - const docRange = currentDocSelections[0]; - const offset = docRange.startOffset - 1; const dataStream = (editor?.getDocumentData().body?.dataStream ?? '\r\n').slice(0, -2); const sequenceNodes = lexerTreeBuilder.sequenceNodesBuilder(dataStream.slice(1)) ?? []; + let offset: number; + + if (currentDocSelections?.length === 1) { + offset = currentDocSelections[0].startOffset - 1; + } else if (dataStream.startsWith('=')) { + offset = Math.max(dataStream.length - 1, 0); + } else { + return; + } + const nodeIndex = findIndexFromSequenceNodes(sequenceNodes, offset, false); const updatingRefIndex = findRefSequenceIndex(sequenceNodes, nodeIndex); return { nodeIndex, updatingRefIndex, + formulaText: dataStream.slice(1), sequenceNodes, offset, }; }; +export function getSequenceNodeCharAtOffset(sequenceNodes: (string | { token: string })[], offset: number): string | undefined { + let currentOffset = 0; + for (const node of sequenceNodes) { + const text = typeof node === 'string' ? node : node.token; + const nextOffset = currentOffset + text.length; + if (offset > currentOffset && offset <= nextOffset) { + return text[offset - currentOffset - 1]; + } + currentOffset = nextOffset; + } + + return undefined; +} + +export function isFormulaReferenceAddingContext(sequenceNodes: (string | { token: string })[], offset: number): boolean { + const char = getSequenceNodeCharAtOffset(sequenceNodes, offset); + return Boolean(char && matchRefDrawToken(char)); +} + +export function isFormulaReferenceAddingTextContext(formulaText: string, offset: number): boolean { + const char = formulaText[offset - 1]; + const nextChar = formulaText[offset]; + return Boolean(char && matchRefDrawToken(char) && (!nextChar || (isFormulaLexerToken(nextChar) && nextChar !== matchToken.OPEN_BRACKET))); +} + +export function insertFormulaReferenceText(formulaText: string, refText: string, offset: number): string { + return `${formulaText.slice(0, offset)}${refText}${formulaText.slice(offset)}`; +} + +export function shouldSkipFormulaReferenceUpdate(isAdd: boolean, selectionCount: number): boolean { + return !isAdd && selectionCount === 0; +} + export function getSelectionsForFormulaRefUpdate( selections: IRange[], updatingRefIndex: number, @@ -95,24 +141,107 @@ export function getSelectionsForFormulaRefUpdate( return { orderedSelections, insertedSelection }; } +export function getLastFormulaSelection(selections: IRange[]): IRange | undefined { + return selections[selections.length - 1]; +} + +export interface ISelectionChangeDuplicateEndGuard { + shouldSkip(selections: TSelection[], isEnd: boolean): boolean; + reset(): void; +} + +export function getFormulaSelectionIdentityKey(selection: unknown): string { + const item = selection as Partial & { range?: Partial; rangeWithCoord?: Partial }; + const range = item.rangeWithCoord ?? item.range ?? item; + return [ + range.startRow ?? '', + range.endRow ?? '', + range.startColumn ?? '', + range.endColumn ?? '', + ].join(':'); +} + +export function isSameFormulaSelection(first: unknown, second: unknown): boolean { + return getFormulaSelectionIdentityKey(first) === getFormulaSelectionIdentityKey(second); +} + +const SHARED_SELECTION_CHANGE_DUPLICATE_END_GUARDS = new Map>(); + +export function getSharedSelectionChangeDuplicateEndGuard(key: string): ISelectionChangeDuplicateEndGuard { + let guard = SHARED_SELECTION_CHANGE_DUPLICATE_END_GUARDS.get(key); + if (!guard) { + guard = createSelectionChangeDuplicateEndGuard(); + SHARED_SELECTION_CHANGE_DUPLICATE_END_GUARDS.set(key, guard); + } + + return guard; +} + +export function createSelectionChangeDuplicateEndGuard(): ISelectionChangeDuplicateEndGuard { + let lastTransientSelectionsKey: string | undefined; + const getSelectionsKey = (selections: TSelection[]) => selections.map(getFormulaSelectionIdentityKey).join('|'); + + return { + shouldSkip(selections, isEnd) { + const selectionsKey = getSelectionsKey(selections); + if (selectionsKey === lastTransientSelectionsKey) { + return true; + } + + if (isEnd) { + lastTransientSelectionsKey = undefined; + return false; + } + + lastTransientSelectionsKey = selectionsKey; + return false; + }, + reset() { + lastTransientSelectionsKey = undefined; + }, + }; +} + export function createSelectionChangeHandler(opts: { initialSelectionsCount: number; onSelectionsChange: (selections: TSelection[], isEnd: boolean, isCtrlAddMode?: boolean) => void; + onDuplicateEnd?: (selections: TSelection[]) => void; + duplicateEndGuard?: ISelectionChangeDuplicateEndGuard; }) { let prevSelectionsCount = opts.initialSelectionsCount; let pendingCtrlAddCount = 0; + const duplicateEndGuard = opts.duplicateEndGuard ?? createSelectionChangeDuplicateEndGuard(); return (selections: TSelection[], isEnd: boolean, options?: { initial?: boolean }) => { if (options?.initial) { + // Ignore the BehaviorSubject replay when subscribing; real user selections arrive through later move events. + return; + } + + if (selections.length === 0) { return; } - const isCtrlAddMode = selections.length > prevSelectionsCount; + const isCtrlAddMode = !options?.initial && prevSelectionsCount > 0 && selections.length > prevSelectionsCount; if (isCtrlAddMode && !isEnd) { pendingCtrlAddCount = selections.length; + if (duplicateEndGuard.shouldSkip(selections, false)) { + return; + } + opts.onSelectionsChange(selections, false, true); + prevSelectionsCount = selections.length; + pendingCtrlAddCount = 0; return; } const shouldApplyPendingCtrlAdd = isEnd && selections.length === pendingCtrlAddCount; + if (duplicateEndGuard.shouldSkip(selections, isEnd)) { + if (isEnd) { + opts.onDuplicateEnd?.(selections); + } + prevSelectionsCount = selections.length; + pendingCtrlAddCount = 0; + return; + } if (isEnd) { prevSelectionsCount = selections.length; @@ -123,6 +252,30 @@ export function createSelectionChangeHandler(opts: { }; } +export function replaceFormulaControlSelection( + selections: IRange[], + index: number, + newRange: IRange +): IRange[] | undefined { + const current = selections[index]; + if (!current) { + return undefined; + } + + const nextRange = { + ...newRange, + sheetId: current.sheetId, + unitId: current.unitId, + }; + const nextSelections = [...selections]; + nextSelections[index] = nextRange; + return nextSelections; +} + +export function getInitialFormulaReferenceSelectionCount(renderSelectionCount: number, formulaReferenceCount: number, selectingType?: FormulaSelectingType): number { + return Math.max(renderSelectionCount, formulaReferenceCount); +} + export const useSheetSelectionChange = ( isNeed: boolean, isFocus: boolean, @@ -138,7 +291,9 @@ export const useSheetSelectionChange = ( const renderManagerService = useDependency(IRenderManagerService); const univerInstanceService = useDependency(IUniverInstanceService); const commandService = useDependency(ICommandService); + const contextService = useDependency(IContextService); const docSelectionManagerService = useDependency(DocSelectionManagerService); + const editorService = useDependency(IEditorService); const themeService = useDependency(ThemeService); const lexerTreeBuilder = useDependency(LexerTreeBuilder); @@ -148,45 +303,67 @@ export const useSheetSelectionChange = ( const activeSheet = useObservable(workbook?.activeSheet$); const contextRef = useStateRef({ activeSheet, sheetName }); const currentUnit = useObservable(useMemo(() => univerInstanceService.getCurrentTypeOfUnit$(UniverInstanceType.UNIVER_SHEET), [univerInstanceService])); - const render = renderManagerService.getRenderById(currentUnit?.getUnitId() ?? ''); + const activeWorkbook = resolveFormulaSelectionWorkbook(currentUnit, workbook); + const render = renderManagerService.getRenderById(activeWorkbook?.getUnitId() ?? unitId); const refSelectionsRenderService = render?.with(RefSelectionsRenderService); const sheetSkeletonManagerService = render?.with(SheetSkeletonManagerService); const refSelectionsService = useDependency(IRefSelectionsService); + const duplicateEndGuard = useMemo(() => getSharedSelectionChangeDuplicateEndGuard(`${unitId}:${subUnitId}`), [subUnitId, unitId]); // eslint-disable-next-line complexity const onSelectionsChange = useEvent((selections: IRange[], isEnd: boolean, isCtrlAddMode?: boolean) => { + if (!editor || !isFormulaEditorInteractionOwner(editorService.getFocusId(), editor.getEditorId(), { + fxBarFocused: contextService.getContextValue(FOCUSING_FX_BAR_EDITOR), + })) { + return; + } + const ctx = prepareSelectionChangeContext({ editor, lexerTreeBuilder }); if (!ctx) return; - const { nodeIndex, updatingRefIndex, sequenceNodes, offset } = ctx; - if (isSelectingRef.current === FormulaSelectingType.NEED_ADD) { + const { nodeIndex, updatingRefIndex, formulaText, sequenceNodes, offset } = ctx; + const isAddingReference = isSelectingRef.current === FormulaSelectingType.NEED_ADD || + isFormulaReferenceAddingContext(sequenceNodes, offset) || + isFormulaReferenceAddingTextContext(formulaText, offset); + if (isAddingReference) { if (offset !== 0) { if (nodeIndex === -1 && sequenceNodes.length) { return; } - const range = selections[selections.length - 1]; + const range = getLastFormulaSelection(selections); + if (!range) { + return; + } const lastNodes = sequenceNodes.splice(nodeIndex + 1); const rangeSheetId = range.sheetId ?? subUnitId; const unitRangeName = { range, - unitId: range.unitId ?? currentUnit!.getUnitId(), - sheetName: getSheetNameById(range.unitId ?? currentUnit!.getUnitId(), rangeSheetId), + unitId: range.unitId ?? activeWorkbook!.getUnitId(), + sheetName: getSheetNameById(range.unitId ?? activeWorkbook!.getUnitId(), rangeSheetId), }; const isAcrossSheet = rangeSheetId !== subUnitId; - const isAcrossWorkbook = currentUnit?.getUnitId() !== unitId; + const isAcrossWorkbook = activeWorkbook?.getUnitId() !== unitId; const refRanges = unitRangesToText([unitRangeName], isSupportAcrossSheet && (isAcrossSheet || isAcrossWorkbook), sheetName, isAcrossWorkbook); + if (isFormulaReferenceAddingTextContext(formulaText, offset)) { + const result = insertFormulaReferenceText(formulaText, refRanges[0], offset); + handleRangeChange(result, offset + refRanges[0].length, isEnd); + return; + } sequenceNodes.push({ token: refRanges[0], nodeType: sequenceNodeType.REFERENCE } as any); const newSequenceNodes = [...sequenceNodes, ...lastNodes]; const result = sequenceNodeToText(newSequenceNodes); handleRangeChange(result, getOffsetFromSequenceNodes(sequenceNodes), isEnd); } else { - const range = selections[selections.length - 1]; + const range = getLastFormulaSelection(selections); + if (!range) { + return; + } const rangeSheetId = range.sheetId ?? subUnitId; const unitRangeName = { range, - unitId: range.unitId ?? currentUnit!.getUnitId(), - sheetName: getSheetNameById(range.unitId ?? currentUnit!.getUnitId(), rangeSheetId), + unitId: range.unitId ?? activeWorkbook!.getUnitId(), + sheetName: getSheetNameById(range.unitId ?? activeWorkbook!.getUnitId(), rangeSheetId), }; const isAcrossSheet = rangeSheetId !== subUnitId; - const isAcrossWorkbook = currentUnit?.getUnitId() !== unitId; + const isAcrossWorkbook = activeWorkbook?.getUnitId() !== unitId; const refRanges = unitRangesToText([unitRangeName], isSupportAcrossSheet && (isAcrossSheet || isAcrossWorkbook), sheetName, isAcrossWorkbook); sequenceNodes.unshift({ token: refRanges[0], nodeType: sequenceNodeType.REFERENCE } as any); const result = sequenceNodeToText(sequenceNodes); @@ -198,9 +375,9 @@ export const useSheetSelectionChange = ( const node = sequenceNodes[nodeIndex]; if (typeof node === 'object' && node.nodeType === sequenceNodeType.REFERENCE) { const oldToken = node.token; - const isAcrossWorkbook = currentUnit?.getUnitId() !== unitId; + const isAcrossWorkbook = activeWorkbook?.getUnitId() !== unitId; if (isAcrossWorkbook) { - node.token = serializeRangeWithSpreadsheet(currentUnit?.getUnitId() ?? '', sheetName, last); + node.token = serializeRangeWithSpreadsheet(activeWorkbook?.getUnitId() ?? '', sheetName, last); } else { node.token = sheetName === activeSheet?.getName() ? serializeRange(last) : serializeRangeWithSheet(activeSheet!.getName(), last); } @@ -213,10 +390,10 @@ export const useSheetSelectionChange = ( const rangeSheetId = range.sheetId ?? subUnitId; const unitRangeName = { range, - unitId: range.unitId ?? currentUnit!.getUnitId(), - sheetName: getSheetNameById(range.unitId ?? currentUnit!.getUnitId(), rangeSheetId), + unitId: range.unitId ?? activeWorkbook!.getUnitId(), + sheetName: getSheetNameById(range.unitId ?? activeWorkbook!.getUnitId(), rangeSheetId), }; - const isAcrossWorkbook = currentUnit?.getUnitId() !== unitId; + const isAcrossWorkbook = activeWorkbook?.getUnitId() !== unitId; const isAcrossSheet = rangeSheetId !== subUnitId; const refRanges = unitRangesToText([unitRangeName], isSupportAcrossSheet && (isAcrossSheet || isAcrossWorkbook), sheetName, isAcrossWorkbook); return refRanges[0]; @@ -233,7 +410,7 @@ export const useSheetSelectionChange = ( nodeRange.sheetName = sheetName; } - if (((nodeRange.unitId || unitId) !== currentUnit?.getUnitId())) { + if (((nodeRange.unitId || unitId) !== activeWorkbook?.getUnitId())) { return item.token; } @@ -281,20 +458,34 @@ export const useSheetSelectionChange = ( useEffect(() => { if (refSelectionsRenderService && isNeed) { - const initialSelectionsCount = Math.max( + const initialSelectionsCount = getInitialFormulaReferenceSelectionCount( refSelectionsRenderService.getSelectionDataWithStyle().length, - refSelectionsService.getCurrentSelections().length, - getRefSelections().length + getRefSelections().length, + isSelectingRef.current ); const handleSelectionsChange = createSelectionChangeHandler({ initialSelectionsCount, + duplicateEndGuard: { + shouldSkip: (selections, isEnd) => duplicateEndGuard.shouldSkip(selections.map((i) => i.rangeWithCoord), isEnd), + reset: duplicateEndGuard.reset, + }, onSelectionsChange: (selections, isEnd, isCtrlAddMode) => { onSelectionsChange(selections.map((i) => i.rangeWithCoord), isEnd, isCtrlAddMode); }, + onDuplicateEnd: () => { + const ctx = prepareSelectionChangeContext({ editor, lexerTreeBuilder }); + if (!ctx) { + return; + } + handleRangeChange(ctx.formulaText, ctx.offset, true); + }, }); let isInitialMoveEnd = true; const disposableCollection = new DisposableCollection(); + disposableCollection.add(refSelectionsRenderService.selectionMoveStart$.subscribe((selections) => { + handleSelectionsChange(selections, false); + })); disposableCollection.add(refSelectionsRenderService.selectionMoving$.subscribe((selections) => { handleSelectionsChange(selections, false); })); @@ -321,11 +512,10 @@ export const useSheetSelectionChange = ( control.selectionScaling$ .subscribe((newRange) => { const selections = refSelectionsRenderService.getSelectionDataWithStyle().map((i) => i.rangeWithCoord); - const current = selections[index]; - newRange.sheetId = current.sheetId; - newRange.unitId = current.unitId; - selections[index] = newRange; - onSelectionsChange(selections, false); + const nextSelections = replaceFormulaControlSelection(selections, index, newRange); + if (nextSelections) { + onSelectionsChange(nextSelections, false); + } }) ); @@ -333,11 +523,10 @@ export const useSheetSelectionChange = ( control.selectionMoving$ .subscribe((newRange) => { const selections = refSelectionsRenderService.getSelectionDataWithStyle().map((i) => i.rangeWithCoord); - const current = selections[index]; - newRange.sheetId = current.sheetId; - newRange.unitId = current.unitId; - selections[index] = newRange; - onSelectionsChange(selections, true); + const nextSelections = replaceFormulaControlSelection(selections, index, newRange); + if (nextSelections) { + onSelectionsChange(nextSelections, true); + } }) ); }); @@ -367,6 +556,11 @@ export const useSheetSelectionChange = ( if (commandInfo.id !== SetSelectionsOperation.id) { return; } + if (!editor || !isFormulaEditorInteractionOwner(editorService.getFocusId(), editor.getEditorId(), { + fxBarFocused: contextService.getContextValue(FOCUSING_FX_BAR_EDITOR), + })) { + return; + } const params = commandInfo.params as ISetSelectionsOperationParams; if (params.extra !== 'formula-editor') { @@ -386,13 +580,27 @@ export const useSheetSelectionChange = ( range.unitId = params.unitId; range.sheetId = params.subUnitId; - const isAdd = isSelectingRef.current === FormulaSelectingType.NEED_ADD; + const ctx = prepareSelectionChangeContext({ editor, lexerTreeBuilder }); + const isAdd = isSelectingRef.current === FormulaSelectingType.NEED_ADD || + Boolean(ctx && ( + isFormulaReferenceAddingContext(ctx.sequenceNodes, ctx.offset) || + isFormulaReferenceAddingTextContext(ctx.formulaText, ctx.offset) + )); const selections: IRange[] = (refSelectionsRenderService?.getSelectionDataWithStyle() ?? []).map((i) => i.rangeWithCoord); if (isAdd) { + if (selections.length > 0 && isSameFormulaSelection(selections[selections.length - 1], range)) { + return; + } selections.push(range); } else { + if (shouldSkipFormulaReferenceUpdate(isAdd, selections.length)) { + return; + } selections[selections.length - 1] = range; } + if (duplicateEndGuard.shouldSkip(selections, true)) { + return; + } onSelectionsChange(selections, true); } } @@ -402,7 +610,7 @@ export const useSheetSelectionChange = ( d.dispose(); }; } - }, [commandService, editor, isSelectingRef, lexerTreeBuilder, listenSelectionSet, onSelectionsChange, refSelectionsRenderService]); + }, [commandService, contextService, duplicateEndGuard, editor, editorService, isSelectingRef, lexerTreeBuilder, listenSelectionSet, onSelectionsChange, refSelectionsRenderService]); useEffect(() => { if (!editor) { @@ -423,7 +631,7 @@ export const useSheetSelectionChange = ( sheetSkeletonManagerService, themeService, univerInstanceService, - currentWorkbook: currentUnit!, + currentWorkbook: activeWorkbook!, }); }); diff --git a/packages/sheets-formula-ui/src/views/formula-editor/index.tsx b/packages/sheets-formula-ui/src/views/formula-editor/index.tsx index a05185e8d5ef..e1c870f6b23e 100644 --- a/packages/sheets-formula-ui/src/views/formula-editor/index.tsx +++ b/packages/sheets-formula-ui/src/views/formula-editor/index.tsx @@ -24,6 +24,7 @@ import type { IRefSelection } from './hooks/use-highlight'; import { BuildTextUtils, createInternalEditorID, + DisposableCollection, DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY, DOCS_NORMAL_EDITOR_UNIT_ID_KEY, DocumentFlavor, @@ -31,8 +32,10 @@ import { HorizontalAlign, ICommandService, IConfigService, + Injector, IUniverInstanceService, noop, + toDisposable, UniverInstanceType, VerticalAlign, } from '@univerjs/core'; @@ -44,9 +47,15 @@ import { useDependency, useEvent, useObservable, useUpdateEffect } from '@univer import { forwardRef, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { PLUGIN_CONFIG_KEY_BASE } from '../../config/config'; import { findIndexFromSequenceNodes, findRefSequenceIndex } from '../range-selector/utils/find-index-from-sequence-nodes'; +import { + IFormulaEmbedInteractionBoundaryService, + IFormulaEmbedRuntimeFocusCoordinator, + resolveActiveFormulaEmbedRuntimeDomScope, + resolveFormulaEmbedRuntimeDomScope, +} from './formula-embed-integration.service'; import { HelpFunction } from './help-function/HelpFunction'; -import { useFocus } from './hooks/use-focus'; -import { useFormulaSelecting } from './hooks/use-formula-selection'; +import { hasActiveFormulaEmbedInteraction, shouldRefocusFormulaEditorOnMouseUp, useFocus } from './hooks/use-focus'; +import { getSelectionAfterLaggingFormulaInput, useFormulaSelecting } from './hooks/use-formula-selection'; import { useFormulaToken } from './hooks/use-formula-token'; import { useDocHight, useSheetHighlight } from './hooks/use-highlight'; import { useLeftAndRightArrow } from './hooks/use-left-and-right-arrow'; @@ -97,6 +106,14 @@ export interface IFormulaEditorRef { isClickOutSide: (e: MouseEvent) => boolean; } +export function shouldApplyFormulaSelectionChange( + selectingMode: FormulaSelectingType | number, + isFocusing: boolean | undefined, + hasActiveInteraction: boolean +): boolean { + return Boolean(selectingMode) || Boolean(isFocusing) || hasActiveInteraction; +} + interface IFormulaEditorSelectionSyncService { getEditor: (editorId: string) => Pick | null | undefined | void; } @@ -120,9 +137,123 @@ export function syncCounterpartFormulaEditorSelection( return; } - editorService.getEditor(syncEditorId)?.setSelectionRanges(selections); + editorService.getEditor(syncEditorId)?.setSelectionRanges(selections, false); +} + +export function registerFormulaEditorRuntimePortal(options: { + embedId: string; + editorId: string; + ownerDocument?: Document; + interactionBoundaryService?: IFormulaEmbedInteractionBoundaryService; + focusCoordinator?: IFormulaEmbedRuntimeFocusCoordinator; +}): IDisposable { + const ownerDocument = options.ownerDocument ?? (typeof document === 'undefined' ? undefined : document); + if (!ownerDocument) { + return toDisposable(() => {}); + } + + const collection = new DisposableCollection(); + const view = ownerDocument.defaultView; + const frameHandles: number[] = []; + let observer: MutationObserver | undefined; + let portalRegistration: IDisposable | undefined; + let registeredPortalRoot: HTMLElement | null = null; + let disposed = false; + + const tryRegister = () => { + if (disposed) { + return; + } + + const portalRoot = resolveFormulaEditorPortalRoot(options.editorId, ownerDocument); + if (portalRoot === registeredPortalRoot) { + return; + } + + portalRegistration?.dispose(); + portalRegistration = undefined; + registeredPortalRoot = null; + if (!portalRoot) { + return; + } + + const rootRegistration = new DisposableCollection(); + registeredPortalRoot = portalRoot; + if (options.interactionBoundaryService) { + rootRegistration.add(options.interactionBoundaryService.registerOwnedElement(options.embedId, portalRoot)); + const editorElement = ownerDocument.getElementById(`__editor_${options.editorId}`) as HTMLElement | null; + if (editorElement && editorElement !== portalRoot) { + rootRegistration.add(options.interactionBoundaryService.registerOwnedElement(options.embedId, editorElement)); + } + } + + if (options.focusCoordinator) { + rootRegistration.add(options.focusCoordinator.registerElement({ + embedId: options.embedId, + role: 'child-editor', + element: portalRoot, + })); + + const editorElement = ownerDocument.getElementById(`__editor_${options.editorId}`) as HTMLElement | null; + if (editorElement && editorElement !== portalRoot) { + rootRegistration.add(options.focusCoordinator.registerElement({ + embedId: options.embedId, + role: 'child-editor', + element: editorElement, + })); + } + } + portalRegistration = rootRegistration; + }; + + const scheduleRetry = (remaining: number) => { + if (remaining <= 0 || !view?.requestAnimationFrame) { + return; + } + + const handle = view.requestAnimationFrame(() => { + const index = frameHandles.indexOf(handle); + if (index >= 0) { + frameHandles.splice(index, 1); + } + tryRegister(); + if (!registeredPortalRoot) { + scheduleRetry(remaining - 1); + } + }); + frameHandles.push(handle); + }; + + tryRegister(); + if (!registeredPortalRoot) { + scheduleRetry(2); + } + if (view?.MutationObserver && ownerDocument.body) { + observer = new view.MutationObserver(() => tryRegister()); + observer.observe(ownerDocument.body, { childList: true, subtree: true }); + } + + collection.add(toDisposable(() => { + disposed = true; + frameHandles.forEach((handle) => view?.cancelAnimationFrame?.(handle)); + frameHandles.length = 0; + observer?.disconnect(); + observer = undefined; + portalRegistration?.dispose(); + portalRegistration = undefined; + registeredPortalRoot = null; + })); + + return collection; } +function resolveFormulaEditorPortalRoot(editorId: string, ownerDocument: Document): HTMLElement | null { + return (ownerDocument.getElementById(`univer-doc-selection-container-${editorId}`) as HTMLElement | null) + ?? (ownerDocument.getElementById(`__editor_${editorId}`) as HTMLElement | null); +} + +export { getSelectionAfterLaggingFormulaInput } from './hooks/use-formula-selection'; + export const FormulaEditor = forwardRef((props: IFormulaEditorProps, ref: Ref) => { const { errorText, @@ -154,6 +285,7 @@ export const FormulaEditor = forwardRef((props: IFormulaEditorProps, ref: Ref(null); const onChange = useEvent(propOnChange); useImperativeHandle(ref, () => ({ @@ -167,9 +299,9 @@ export const FormulaEditor = forwardRef((props: IFormulaEditorProps, ref: Ref(null); const editorRef = useRef(undefined); - const editor = editorRef.current; + const [editor, setEditor] = useState(); const [isFocus, setIsFocus] = useState(_isFocus); - const formulaEditorContainerRef = useRef(null); + const formulaEditorContainerRef = useRef(null); const editorId = useMemo(() => propEditorId ?? createInternalEditorID(`${EMBEDDING_FORMULA_EDITOR}-${generateRandomId(4)}`), []); const isError = useMemo(() => errorText !== undefined, [errorText]); const univerInstanceService = useDependency(IUniverInstanceService); @@ -180,7 +312,7 @@ export const FormulaEditor = forwardRef((props: IFormulaEditorProps, ref: Ref getFormulaText(formulaText), [formulaText]); const sequenceNodes = useMemo(() => getFormulaToken(formulaWithoutEqualSymbol), [formulaWithoutEqualSymbol, getFormulaToken]); - const { isSelecting, isSelectingRef } = useFormulaSelecting({ unitId, subUnitId, editorId, isFocus, disableOnClick: disableSelectionOnClick }); + const { isSelecting, isSelectingRef } = useFormulaSelecting({ unitId, subUnitId, editor, editorId, isFocus, disableOnClick: disableSelectionOnClick }); const highTextRef = useRef(''); const renderManagerService = useDependency(IRenderManagerService); const renderer = renderManagerService.getRenderById(editorId); @@ -191,6 +323,7 @@ export const FormulaEditor = forwardRef((props: IFormulaEditorProps, ref: Ref refSelections.current); + const getRefSelectionCount = useEvent(() => getRefSelections().length); const selectingMode = isSelecting; // whether to hide formula search and help popup @@ -201,6 +334,33 @@ export const FormulaEditor = forwardRef((props: IFormulaEditorProps, ref: Ref { + if (!isFocus || !editor) { + return undefined; + } + + const subscription = editor.input$.subscribe(({ content, isComposing }) => { + if (isComposing) { + return; + } + + queueMicrotask(() => { + const selection = editor.getSelectionRanges()?.[0]; + const dataStream = editor.getDocumentData().body?.dataStream ?? ''; + const normalizedSelection = getSelectionAfterLaggingFormulaInput(dataStream, selection, content); + if (!normalizedSelection) { + return; + } + + const selections = [normalizedSelection]; + editor.setSelectionRanges(selections, false); + syncCounterpartFormulaEditorSelection(editorService, editorId, selections); + }); + }); + + return () => subscription.unsubscribe(); + }, [editor, editorId, editorService, isFocus]); + const highlightDoc = useDocHight('='); const highlightSheet = useSheetHighlight(unitId, subUnitId); const highlight = useEvent((text: string, isNeedResetSelection: boolean = true, isEnd?: boolean, newSelections?: ITextRange[]) => { @@ -208,31 +368,30 @@ export const FormulaEditor = forwardRef((props: IFormulaEditorProps, ref: Ref (typeof cur === 'object' ? `${pre}${cur.token}` : `${pre}${cur}`), ''); const ranges = highlightDoc( editorRef.current, - parsedFormula === formulaStr ? sequenceNodes : [], + sequenceNodes, isNeedResetSelection, - newSelections + newSelections, + formulaStr ); refSelections.current = ranges; if (isEnd) { const currentDocSelections = newSelections ?? editor?.getSelectionRanges(); - if (currentDocSelections?.length !== 1) { - return; - } - const docRange = currentDocSelections[0]; - const offset = docRange.startOffset - 1; - const nodeIndex = findIndexFromSequenceNodes(sequenceNodes, offset, false); - const refIndex = findRefSequenceIndex(sequenceNodes, nodeIndex); - // make sure current editing selection is at the end - if (refIndex >= 0) { - const target = ranges.splice(refIndex, 1)[0]; - target && ranges.push(target); + if (currentDocSelections?.length === 1) { + const docRange = currentDocSelections[0]; + const offset = docRange.startOffset - 1; + const nodeIndex = findIndexFromSequenceNodes(sequenceNodes, offset, false); + const refIndex = findRefSequenceIndex(sequenceNodes, nodeIndex); + // make sure current editing selection is at the end + if (refIndex >= 0) { + const target = ranges.splice(refIndex, 1)[0]; + target && ranges.push(target); + } } - highlightSheet(isFocus ? ranges : [], editorRef.current); + highlightSheet(isFocus ? ranges : [], editorRef.current, isEnd); } }); @@ -304,16 +463,64 @@ export const FormulaEditor = forwardRef((props: IFormulaEditorProps, ref: Ref { + editorRef.current = undefined; dispose?.dispose(); }; }, []); + useEffect(() => { + const formulaEditorContainer = formulaEditorContainerRef.current; + if (!isFocus || !formulaEditorContainer || !injector.has(IFormulaEmbedRuntimeFocusCoordinator)) { + return undefined; + } + + const focusCoordinator = injector.get(IFormulaEmbedRuntimeFocusCoordinator); + const scope = resolveFormulaEmbedRuntimeDomScope(formulaEditorContainer) ?? + focusCoordinator.resolveRuntimeScopeByChildUnitId(editorId) ?? + resolveActiveFormulaEmbedRuntimeDomScope(formulaEditorContainer.ownerDocument); + if (!scope) { + return undefined; + } + + const collection = new DisposableCollection(); + const interactionBoundaryService = injector.has(IFormulaEmbedInteractionBoundaryService) + ? injector.get(IFormulaEmbedInteractionBoundaryService) + : undefined; + + collection.add(focusCoordinator.acquireLease({ + embedId: scope.embedId, + role: 'child-editor', + owner: 'sheet-formula-editor', + hostUnitId: scope.hostUnitId, + childUnitId: scope.childUnitId, + associatedChildUnitIds: [editorId], + })); + if (interactionBoundaryService) { + collection.add(interactionBoundaryService.registerOwnedElement(scope.embedId, formulaEditorContainer)); + } + collection.add(focusCoordinator.registerElement({ + embedId: scope.embedId, + role: 'child-editor', + element: formulaEditorContainer, + })); + collection.add(registerFormulaEditorRuntimePortal({ + embedId: scope.embedId, + editorId, + ownerDocument: formulaEditorContainer.ownerDocument, + interactionBoundaryService, + focusCoordinator, + })); + + return () => collection.dispose(); + }, [editorId, injector, isFocus]); + useLayoutEffect(() => { let focusRetryFrame = 0; let finalFocusRetryFrame = 0; @@ -347,11 +554,15 @@ export const FormulaEditor = forwardRef((props: IFormulaEditorProps, ref: Ref { - if (!isFocusing) { + if (!shouldApplyFormulaSelectionChange( + selectingMode, + isFocusing, + hasActiveFormulaEmbedInteraction(formulaEditorContainerRef.current) + )) { return; } @@ -371,7 +582,7 @@ export const FormulaEditor = forwardRef((props: IFormulaEditorProps, ref: Ref { + const handleMouseUp = (event: React.MouseEvent) => { + if (hasActiveFormulaEmbedInteraction(formulaEditorContainerRef.current)) { + return; + } + if (!shouldRefocusFormulaEditorOnMouseUp({ + target: event.target, + isFocusing, + isPointerSelecting: docSelectionRenderService?.isOnPointerEvent, + })) { + return; + } setIsFocus(true); onFocus(); focus(); @@ -424,6 +645,7 @@ export const FormulaEditor = forwardRef((props: IFormulaEditorProps, ref: Ref
diff --git a/packages/sheets-numfmt-ui/src/controllers/ui.controller.ts b/packages/sheets-numfmt-ui/src/controllers/ui.controller.ts index be018424f061..824f86405f4f 100644 --- a/packages/sheets-numfmt-ui/src/controllers/ui.controller.ts +++ b/packages/sheets-numfmt-ui/src/controllers/ui.controller.ts @@ -170,9 +170,12 @@ export class SheetNumfmtUIController extends Disposable { }; private _forceUpdate(unitId?: string): void { - const renderUnit = this._renderManagerService.getRenderById( - unitId ?? this._univerInstanceService.getCurrentUnitOfType(UniverInstanceType.UNIVER_SHEET)!.getUnitId() - ); + const resolvedUnitId = unitId ?? this._univerInstanceService.getCurrentUnitOfType(UniverInstanceType.UNIVER_SHEET)?.getUnitId(); + if (!resolvedUnitId) { + return; + } + + const renderUnit = this._renderManagerService.getRenderById(resolvedUnitId); renderUnit?.with(SheetSkeletonManagerService).reCalculate(); renderUnit?.mainComponent?.makeDirty(); diff --git a/packages/sheets-ui/src/commands/commands/__tests__/command-behavior.spec.ts b/packages/sheets-ui/src/commands/commands/__tests__/command-behavior.spec.ts index 907a708156ea..580e8cb9d4d8 100644 --- a/packages/sheets-ui/src/commands/commands/__tests__/command-behavior.spec.ts +++ b/packages/sheets-ui/src/commands/commands/__tests__/command-behavior.spec.ts @@ -260,6 +260,13 @@ describe('sheets-ui command behaviors', () => { const workbook = { getUnitId: () => 'unit-1', getActiveSheet: () => worksheet, + getSheetBySheetId: (sheetId: string) => sheetId === 'sheet-1' ? worksheet : undefined, + }; + const embeddedWorksheet = { getSheetId: () => 'embedded-sheet', getConfig: () => ({ freeze: { xSplit: 0, ySplit: 0 } }) }; + const embeddedWorkbook = { + getUnitId: () => 'embedded-unit', + getActiveSheet: () => embeddedWorksheet, + getSheetBySheetId: (sheetId: string) => sheetId === 'embedded-sheet' ? embeddedWorksheet : undefined, }; const renderManager = { getRenderById: vi.fn(() => ({ @@ -275,6 +282,7 @@ describe('sheets-ui command behaviors', () => { }; const univerInstanceService = { getCurrentUnitOfType: () => workbook, + getUnit: (unitId: string) => unitId === 'embedded-unit' ? embeddedWorkbook : undefined, }; const accessor = createAccessor([ @@ -303,6 +311,14 @@ describe('sheets-ui command behaviors', () => { offsetY: 30, })); + expect(ScrollCommand.handler(accessor, { unitId: 'embedded-unit', sheetId: 'embedded-sheet', offsetX: 7 } as any)).toBe(true); + expect(renderManager.getRenderById).toHaveBeenLastCalledWith('embedded-unit'); + expect(syncExecuteCommand).toHaveBeenCalledWith(SetScrollOperation.id, expect.objectContaining({ + unitId: 'embedded-unit', + sheetId: 'embedded-sheet', + offsetX: 7, + })); + const scrollToRange = vi.fn(() => true); const scrollAccessor = createAccessor([ [IUniverInstanceService, { getCurrentUnitOfType: () => ({ getUnitId: () => 'unit-1' }) }], diff --git a/packages/sheets-ui/src/commands/commands/set-scroll.command.ts b/packages/sheets-ui/src/commands/commands/set-scroll.command.ts index 03cca7d26ed1..c041523458eb 100644 --- a/packages/sheets-ui/src/commands/commands/set-scroll.command.ts +++ b/packages/sheets-ui/src/commands/commands/set-scroll.command.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import type { ICommand, IRange, Nullable } from '@univerjs/core'; +import type { ICommand, IRange, Nullable, Workbook } from '@univerjs/core'; import type { IScrollState } from '../../services/scroll-manager.service'; import { CommandType, ICommandService, IUniverInstanceService, UniverInstanceType } from '@univerjs/core'; @@ -30,6 +30,8 @@ export interface ISetScrollRelativeCommandParams { } export interface IScrollCommandParams { + unitId?: string; + sheetId?: string; offsetX?: number; offsetY?: number; /** @@ -114,11 +116,16 @@ export const ScrollCommand: ICommand = { const univerInstanceService = accessor.get(IUniverInstanceService); const renderManagerSrv = accessor.get(IRenderManagerService); - const target = getSheetCommandTarget(univerInstanceService); + const target = params.unitId + ? getScrollCommandTargetByParams(univerInstanceService, params) + : getSheetCommandTarget(univerInstanceService); if (!target) return false; const { workbook, worksheet, unitId } = target; - const scrollManagerService = renderManagerSrv.getRenderById(unitId)!.with(SheetScrollManagerService); + const renderUnit = renderManagerSrv.getRenderById(unitId); + if (!renderUnit) return false; + + const scrollManagerService = renderUnit.with(SheetScrollManagerService); const currentScroll: Readonly> = scrollManagerService.getCurrentScrollState(); if (!worksheet) { @@ -150,10 +157,33 @@ export const ScrollCommand: ICommand = { }, }; +function getScrollCommandTargetByParams( + univerInstanceService: IUniverInstanceService, + params: Pick +) { + if (!params.unitId) { + return null; + } + + const workbook = univerInstanceService.getUnit(params.unitId, UniverInstanceType.UNIVER_SHEET); + const worksheet = params.sheetId ? workbook?.getSheetBySheetId(params.sheetId) : workbook?.getActiveSheet(); + if (!workbook || !worksheet) { + return null; + } + + return { + workbook, + worksheet, + unitId: params.unitId, + subUnitId: worksheet.getSheetId(), + }; +} + export interface IScrollToCellCommandParams { range: IRange; forceTop?: boolean; forceLeft?: boolean; + unitId?: string; } /** diff --git a/packages/sheets-ui/src/commands/operations/__tests__/basic-operations.spec.ts b/packages/sheets-ui/src/commands/operations/__tests__/basic-operations.spec.ts index 75d137a6ef6b..a744d9f6a515 100644 --- a/packages/sheets-ui/src/commands/operations/__tests__/basic-operations.spec.ts +++ b/packages/sheets-ui/src/commands/operations/__tests__/basic-operations.spec.ts @@ -120,9 +120,10 @@ describe('sheets-ui basic operations', () => { it('ScrollToRangeOperation should guard params and call scroll controller', () => { const scrollToRange = vi.fn(() => true); + const getRenderById = vi.fn(() => ({ with: () => ({ scrollToRange }) })); const accessor = createAccessor([ [IUniverInstanceService, { getCurrentUnitOfType: () => ({ getUnitId: () => 'u1' }) }], - [IRenderManagerService, { getRenderById: () => ({ with: () => ({ scrollToRange }) }) }], + [IRenderManagerService, { getRenderById }], ]); expect(ScrollToRangeOperation.handler(accessor, undefined as any)).toBe(false); @@ -138,5 +139,20 @@ describe('sheets-ui basic operations', () => { true, false ); + expect(getRenderById).toHaveBeenCalledWith('u1'); + + expect(ScrollToRangeOperation.handler(accessor, { + unitId: 'embedded-u1', + range: { startRow: 2, endRow: 3, startColumn: 2, endColumn: 3 }, + } as any)).toBe(true); + expect(getRenderById).toHaveBeenLastCalledWith('embedded-u1'); + + const missingRenderAccessor = createAccessor([ + [IUniverInstanceService, { getCurrentUnitOfType: () => ({ getUnitId: () => 'missing' }) }], + [IRenderManagerService, { getRenderById: () => null }], + ]); + expect(ScrollToRangeOperation.handler(missingRenderAccessor, { + range: { startRow: 1, endRow: 1, startColumn: 1, endColumn: 1 }, + } as any)).toBe(false); }); }); diff --git a/packages/sheets-ui/src/commands/operations/scroll-to-range.operation.ts b/packages/sheets-ui/src/commands/operations/scroll-to-range.operation.ts index dfd05578de65..7186169431c2 100644 --- a/packages/sheets-ui/src/commands/operations/scroll-to-range.operation.ts +++ b/packages/sheets-ui/src/commands/operations/scroll-to-range.operation.ts @@ -29,9 +29,17 @@ export const ScrollToRangeOperation: ICommand = { } const instanceService = accessor.get(IUniverInstanceService); const renderManagerService = accessor.get(IRenderManagerService); - const scrollController = renderManagerService - .getRenderById(instanceService.getCurrentUnitOfType(UniverInstanceType.UNIVER_SHEET)!.getUnitId())! - .with(SheetsScrollRenderController); + const unitId = params.unitId ?? instanceService.getCurrentUnitOfType(UniverInstanceType.UNIVER_SHEET)?.getUnitId(); + if (!unitId) { + return false; + } + + const renderUnit = renderManagerService.getRenderById(unitId); + if (!renderUnit) { + return false; + } + + const scrollController = renderUnit.with(SheetsScrollRenderController); return scrollController.scrollToRange(params.range, params.forceTop, params.forceLeft); }, diff --git a/packages/sheets-ui/src/controllers/auto-fill-ui.controller.ts b/packages/sheets-ui/src/controllers/auto-fill-ui.controller.ts index 3ceba12b394e..48af5ee6b619 100644 --- a/packages/sheets-ui/src/controllers/auto-fill-ui.controller.ts +++ b/packages/sheets-ui/src/controllers/auto-fill-ui.controller.ts @@ -147,6 +147,21 @@ export class AutoFillUIController extends Disposable { private _initSelectionControlFillChanged() { const disposableCollection = new DisposableCollection(); + let pendingRetry = false; + let retryCount = 0; + + const scheduleUpdateListener = (listener: () => void) => { + if (pendingRetry) { + return; + } + + pendingRetry = true; + setTimeout(() => { + pendingRetry = false; + listener(); + }, 0); + }; + const updateListener = () => { // Each range change requires re-listening. disposableCollection.dispose(); @@ -154,7 +169,17 @@ export class AutoFillUIController extends Disposable { const currentRenderer = getCurrentTypeOfRenderer(UniverInstanceType.UNIVER_SHEET, this._univerInstanceService, this._renderManagerService); if (!currentRenderer) return; - const selectionRenderService = currentRenderer.with(ISheetSelectionRenderService); + const selectionRenderService = getResolvedSelectionRenderService(currentRenderer); + if (!selectionRenderService) { + retryCount += 1; + if (retryCount <= 3) { + scheduleUpdateListener(updateListener); + } + return; + } + + retryCount = 0; + const selectionControls = selectionRenderService.getSelectionControls(); selectionControls.forEach((controlSelection) => { disposableCollection.add(controlSelection.selectionFilled$.subscribe((filled) => { @@ -211,18 +236,18 @@ export class AutoFillUIController extends Disposable { }); }; - updateListener(); + scheduleUpdateListener(updateListener); // Should subscribe current current renderer change as well. // TODO@yuhongz: this seems not ideal. This should be an `IRenderModule` for running with multiple renderers? this.disposeWithMe(this._commandService.onCommandExecuted((command: ICommandInfo) => { if (command.id === SetSelectionsOperation.id) { - updateListener(); + scheduleUpdateListener(updateListener); } })); this.disposeWithMe(this._univerInstanceService.getCurrentTypeOfUnit$(UniverInstanceType.UNIVER_SHEET) - .subscribe(() => updateListener())); + .subscribe(() => scheduleUpdateListener(updateListener))); } private _handleDbClickFill(source: IRange) { @@ -277,3 +302,24 @@ export class AutoFillUIController extends Disposable { }; } } + +function getResolvedSelectionRenderService(renderer: unknown): ISheetSelectionRenderService | undefined { + const injector = (renderer as { getInjector?: () => unknown }).getInjector?.(); + const resolvedDependencies = (injector as { + resolvedDependencyCollection?: { + resolvedDependencies?: Map; + }; + } | undefined)?.resolvedDependencyCollection?.resolvedDependencies; + + if (!resolvedDependencies) { + return undefined; + } + + for (const [identifier, values] of resolvedDependencies) { + if ((identifier as { decoratorName?: unknown }).decoratorName === (ISheetSelectionRenderService as unknown as { decoratorName?: unknown }).decoratorName) { + return values.length === 1 ? values[0] as ISheetSelectionRenderService : undefined; + } + } + + return undefined; +} diff --git a/packages/sheets-ui/src/controllers/editor/__tests__/editing-render-business.spec.ts b/packages/sheets-ui/src/controllers/editor/__tests__/editing-render-business.spec.ts index 4374089b4ab2..92038c8ed172 100644 --- a/packages/sheets-ui/src/controllers/editor/__tests__/editing-render-business.spec.ts +++ b/packages/sheets-ui/src/controllers/editor/__tests__/editing-render-business.spec.ts @@ -22,11 +22,14 @@ import { FOCUSING_EDITOR_INPUT_FORMULA, FOCUSING_FX_BAR_EDITOR, LocaleType, + UniverInstanceType, } from '@univerjs/core'; import { VIEWPORT_KEY as DOC_VIEWPORT_KEY, MoveCursorOperation, MoveSelectionOperation } from '@univerjs/docs-ui'; import { LexerTreeBuilder } from '@univerjs/engine-formula'; +import { DeviceInputEventType } from '@univerjs/engine-render'; import { SetRangeValuesCommand } from '@univerjs/sheets'; import { KeyCode } from '@univerjs/ui'; +import { Subject } from 'rxjs'; import { describe, expect, it, vi } from 'vitest'; import { MoveSelectionCommand, MoveSelectionEnterAndTabCommand } from '../../../commands/commands/set-selection.command'; import { EditingRenderController } from '../editing.render-controller'; @@ -44,7 +47,29 @@ function createController() { getSheetBySheetId: vi.fn(() => worksheet), getStyles: vi.fn(() => styles), }; + let normalSnapshot = { + body: { dataStream: 'new value\r\n', paragraphs: [{ startIndex: 9, paragraphId: 'normal-para' }] }, + documentStyle: {}, + }; + let formulaSnapshot = { + body: { dataStream: 'new value\r\n', paragraphs: [{ startIndex: 9, paragraphId: 'formula-para' }] }, + documentStyle: {}, + }; const docModel = { + getUnitId: vi.fn(() => DOCS_NORMAL_EDITOR_UNIT_ID_KEY), + getSnapshot: vi.fn(() => normalSnapshot), + reset: vi.fn((snapshot) => { + normalSnapshot = snapshot; + }), + }; + const formulaDocModel = { + getUnitId: vi.fn(() => DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY), + getSnapshot: vi.fn(() => formulaSnapshot), + reset: vi.fn((snapshot) => { + formulaSnapshot = snapshot; + }), + }; + const documentModel = { getSnapshot: vi.fn(() => ({ body: { dataStream: 'new value\r\n' }, documentStyle: {}, @@ -68,6 +93,9 @@ function createController() { getContextValue: vi.fn(() => false), }; controller._cellEditorManagerService = { setState: vi.fn() }; + controller._sheetCellEditorResizeService = { + fitTextSize: vi.fn((callback?: () => void) => callback?.()), + }; controller._editorService = { isSheetEditor: vi.fn(() => true), getEditor: vi.fn((editorId: string) => editorId === DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY ? formulaBarEditor : null), @@ -78,12 +106,20 @@ function createController() { sheetId: 'sheet-1', row: 2, column: 3, - documentLayoutObject: { documentModel: null }, + documentLayoutObject: { documentModel }, + })), + getEditLocation: vi.fn(() => ({ + unitId: 'unit-1', + sheetId: 'sheet-1', + row: 2, + column: 3, + documentLayoutObject: { documentModel }, })), - getEditLocation: vi.fn(() => ({ unitId: 'unit-1', sheetId: 'sheet-1', row: 2, column: 3 })), getCurrentEditorId: vi.fn(() => DOCS_NORMAL_EDITOR_UNIT_ID_KEY), isForceKeepVisible: vi.fn(() => false), disableForceKeepVisible: vi.fn(), + refreshEditCellPosition: vi.fn(), + changeEditorDirty: vi.fn(), }; controller._sheetInterceptorService = { onWriteCell: vi.fn((_workbook, _worksheet, _row, _column, cellData) => cellData), @@ -94,10 +130,23 @@ function createController() { executeCommand: vi.fn(), }; controller._univerInstanceService = { - getUnit: vi.fn((unitId: string) => unitId === DOCS_NORMAL_EDITOR_UNIT_ID_KEY ? docModel : workbook), + getUnit: vi.fn((unitId: string) => { + if (unitId === DOCS_NORMAL_EDITOR_UNIT_ID_KEY) { + return docModel; + } + if (unitId === DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY) { + return formulaDocModel; + } + + return workbook; + }), getCurrentUnitOfType: vi.fn(() => workbook), setCurrentUnitForType: vi.fn(), }; + controller._textSelectionManagerService = { + replaceDocRanges: vi.fn(), + refreshSelection: vi.fn(), + }; const workbookSelections = { getCurrentLastSelection: vi.fn(() => ({ range: { startRow: 1, startColumn: 1, endRow: 4, endColumn: 4 } })), getSelectionsOfWorksheet: vi.fn(() => [{ range: { startRow: 2, startColumn: 3, endRow: 2, endColumn: 3 } }]), @@ -111,20 +160,34 @@ function createController() { getViewport: vi.fn((key) => key === DOC_VIEWPORT_KEY.VIEW_MAIN ? { scrollToViewportPos: vi.fn() } : null), + resetCursor: vi.fn(), }, with: vi.fn(() => ({ resetInitialWidth: vi.fn() })), })), }; controller._getEditorObject = vi.fn(() => ({ + document: { + makeDirty: vi.fn(), + }, scene: { getViewport: vi.fn((key) => key === DOC_VIEWPORT_KEY.VIEW_MAIN ? { scrollToViewportPos: vi.fn() } : null), + resetCursor: vi.fn(), }, })); - controller._getEditorSkeleton = vi.fn(() => ({ resetInitialWidth: vi.fn() })); + controller._getEditorSkeleton = vi.fn(() => ({ calculate: vi.fn(), resetInitialWidth: vi.fn() })); + controller._getEditorViewModel = vi.fn(() => ({ reset: vi.fn() })); - return { controller, docModel, formulaBarEditor, workbook, worksheet }; + return { + controller, + docModel, + formulaBarEditor, + getFormulaSnapshot: () => formulaSnapshot, + getNormalSnapshot: () => normalSnapshot, + workbook, + worksheet, + }; } describe('EditingRenderController business methods', () => { @@ -207,4 +270,62 @@ describe('EditingRenderController business methods', () => { expect(formulaBarEditor.setSelectionRanges).toHaveBeenCalledWith([], false); expect(formulaBarEditor.blur).toHaveBeenCalled(); }); + + it('leaves initial keyboard input to the doc input pipeline when opening the cell editor', () => { + const { controller, getFormulaSnapshot, getNormalSnapshot } = createController(); + + controller._handleEditorVisible({ + visible: true, + eventType: DeviceInputEventType.Keyboard, + keycode: 187, + initialValue: '=', + unitId: 'unit-1', + }); + + expect(getNormalSnapshot().body.dataStream).toBe('\r\n'); + expect(getFormulaSnapshot().body.dataStream).toBe('\r\n'); + expect(controller._textSelectionManagerService.replaceDocRanges).toHaveBeenCalledWith( + [{ startOffset: 0, endOffset: 0 }], + { + unitId: DOCS_NORMAL_EDITOR_UNIT_ID_KEY, + subUnitId: DOCS_NORMAL_EDITOR_UNIT_ID_KEY, + } + ); + expect(controller._editorBridgeService.changeEditorDirty).not.toHaveBeenCalled(); + }); + + it('syncs the active sheet editor selection instead of the host document selection on focus', () => { + const { controller, workbook } = createController(); + const focus$ = new Subject(); + const hostSelectionSync = vi.fn(); + const cellEditorSelectionSync = vi.fn(); + const disposableCollection = { add: vi.fn() }; + + controller._cellEditorManagerService.focus$ = focus$; + controller._univerInstanceService.getCurrentUnitOfType.mockImplementation((type: UniverInstanceType) => { + if (type === UniverInstanceType.UNIVER_DOC) { + return { getUnitId: () => 'host-doc' }; + } + + return workbook; + }); + controller._renderManagerService.getRenderById.mockImplementation((unitId: string) => ({ + with: vi.fn(() => { + if (unitId === DOCS_NORMAL_EDITOR_UNIT_ID_KEY) { + return { sync: cellEditorSelectionSync }; + } + if (unitId === 'host-doc') { + return { sync: hostSelectionSync }; + } + + return undefined; + }), + })); + + controller._initialCursorSync(disposableCollection); + focus$.next(true); + + expect(cellEditorSelectionSync).toHaveBeenCalledTimes(1); + expect(hostSelectionSync).not.toHaveBeenCalled(); + }); }); diff --git a/packages/sheets-ui/src/controllers/editor/__tests__/formula-editor.controller.spec.ts b/packages/sheets-ui/src/controllers/editor/__tests__/formula-editor.controller.spec.ts index 25ef625c5d9d..2fbb3e172302 100644 --- a/packages/sheets-ui/src/controllers/editor/__tests__/formula-editor.controller.spec.ts +++ b/packages/sheets-ui/src/controllers/editor/__tests__/formula-editor.controller.spec.ts @@ -31,9 +31,11 @@ import { FormulaEditorController } from '../formula-editor.controller'; function createController() { const fxBtnClick$ = new Subject(); const position = { width: 240, height: 40 }; + let focusEditorId = DOCS_NORMAL_EDITOR_UNIT_ID_KEY; const contextValues = new Map([ [FOCUSING_EDITOR_BUT_HIDDEN, true], [EDITOR_ACTIVATED, false], + [FOCUSING_FX_BAR_EDITOR, false], ]); const scrollBar = { dispose: vi.fn() }; const viewport = { @@ -78,13 +80,20 @@ function createController() { setContextValue: vi.fn((key: string, value: unknown) => contextValues.set(key, value)), }; controller._textSelectionManagerService = { replaceDocRanges: vi.fn() }; + controller._editorService = { + getFocusEditor: vi.fn(() => ({ getEditorId: () => focusEditorId })), + }; return { controller, + contextValues, fxBtnClick$, formulaDoc, mainComponent, scene, + setFocusEditorId: (editorId: string) => { + focusEditorId = editorId; + }, scrollBar, viewport, }; @@ -104,10 +113,12 @@ describe('FormulaEditorController business methods', () => { it('turns hidden formula-bar content into a formula when fx button is clicked', () => { const { controller, fxBtnClick$ } = createController(); - const raf = vi.spyOn(globalThis, 'requestAnimationFrame').mockImplementation((cb: FrameRequestCallback) => { + const originalRequestAnimationFrame = globalThis.requestAnimationFrame; + const raf = vi.fn((cb: FrameRequestCallback) => { cb(0); return 1; }); + vi.stubGlobal('requestAnimationFrame', raf); controller._listenFxBtnClick(); fxBtnClick$.next(); @@ -130,6 +141,29 @@ describe('FormulaEditorController business methods', () => { }); expect(controller._contextService.setContextValue).toHaveBeenCalledWith(FOCUSING_FX_BAR_EDITOR, true); - raf.mockRestore(); + vi.stubGlobal('requestAnimationFrame', originalRequestAnimationFrame); + }); + + it('keeps formula bar focus while formula range picking is active', () => { + const { contextValues, controller, setFocusEditorId } = createController(); + contextValues.set(FOCUSING_FX_BAR_EDITOR, true); + contextValues.set(EDITOR_ACTIVATED, true); + setFocusEditorId(DOCS_NORMAL_EDITOR_UNIT_ID_KEY); + + controller._syncFxBarFocusContext(); + + expect(contextValues.get(FOCUSING_FX_BAR_EDITOR)).toBe(true); + expect(controller._contextService.setContextValue).not.toHaveBeenCalledWith(FOCUSING_FX_BAR_EDITOR, false); + }); + + it('clears formula bar focus when focus leaves outside formula range picking', () => { + const { contextValues, controller, setFocusEditorId } = createController(); + contextValues.set(FOCUSING_FX_BAR_EDITOR, true); + contextValues.set(EDITOR_ACTIVATED, false); + setFocusEditorId(DOCS_NORMAL_EDITOR_UNIT_ID_KEY); + + controller._syncFxBarFocusContext(); + + expect(contextValues.get(FOCUSING_FX_BAR_EDITOR)).toBe(false); }); }); diff --git a/packages/sheets-ui/src/controllers/editor/editing.render-controller.ts b/packages/sheets-ui/src/controllers/editor/editing.render-controller.ts index 1f93af3311da..ddafc6796037 100644 --- a/packages/sheets-ui/src/controllers/editor/editing.render-controller.ts +++ b/packages/sheets-ui/src/controllers/editor/editing.render-controller.ts @@ -233,10 +233,13 @@ export class EditingRenderController extends Disposable { private _initialCursorSync(d: DisposableCollection) { d.add(this._cellEditorManagerService.focus$.pipe(filter((f) => !!f)).subscribe(() => { - const currentDoc = this._univerInstanceService.getCurrentUnitOfType(UniverInstanceType.UNIVER_DOC); - if (!currentDoc) return; + const editorId = this._contextService.getContextValue(FOCUSING_FX_BAR_EDITOR) + ? DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY + : this._editorBridgeService.getCurrentEditorId(); + const docUnitId = editorId ?? this._univerInstanceService.getCurrentUnitOfType(UniverInstanceType.UNIVER_DOC)?.getUnitId(); + if (!docUnitId) return; - const docSelectionRenderManager = this._renderManagerService.getRenderById(currentDoc?.getUnitId())?.with(DocSelectionRenderService); + const docSelectionRenderManager = this._renderManagerService.getRenderById(docUnitId)?.with(DocSelectionRenderService); if (!docSelectionRenderManager) return; docSelectionRenderManager.sync(); @@ -421,7 +424,10 @@ export class EditingRenderController extends Disposable { return; } + const { unitId, isInArrayFormulaRange = false } = editCellState; + this._commandService.syncExecuteCommand(ScrollToRangeOperation.id, { + unitId, range: { startRow: editCellState.row, startColumn: editCellState.column, @@ -431,7 +437,6 @@ export class EditingRenderController extends Disposable { }); this._editorBridgeService.refreshEditCellPosition(false); - const { unitId, isInArrayFormulaRange = false } = editCellState; const editorObject = this._getEditorObject(); if (editorObject == null) { diff --git a/packages/sheets-ui/src/controllers/editor/formula-editor.controller.ts b/packages/sheets-ui/src/controllers/editor/formula-editor.controller.ts index 01dcd91e3cfa..5c22bfb47a0b 100644 --- a/packages/sheets-ui/src/controllers/editor/formula-editor.controller.ts +++ b/packages/sheets-ui/src/controllers/editor/formula-editor.controller.ts @@ -78,15 +78,27 @@ export class FormulaEditorController extends RxDisposable { this._create(DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY); this.disposeWithMe(this._editorService.focus$.subscribe(() => { - const focusUnitId = this._editorService.getFocusEditor()?.getEditorId(); - if (focusUnitId !== DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY) { - this._contextService.setContextValue(FOCUSING_FX_BAR_EDITOR, false); - } else { - this._contextService.setContextValue(FOCUSING_FX_BAR_EDITOR, true); - } + this._syncFxBarFocusContext(); })); } + private _syncFxBarFocusContext(): void { + const focusUnitId = this._editorService.getFocusEditor()?.getEditorId(); + if (focusUnitId === DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY) { + this._contextService.setContextValue(FOCUSING_FX_BAR_EDITOR, true); + return; + } + + if ( + this._contextService.getContextValue(FOCUSING_FX_BAR_EDITOR) && + this._contextService.getContextValue(EDITOR_ACTIVATED) + ) { + return; + } + + this._contextService.setContextValue(FOCUSING_FX_BAR_EDITOR, false); + } + private _handleContentChange() { this.disposeWithMe( this._commandService.onCommandExecuted((commandInfo) => { diff --git a/packages/sheets-ui/src/controllers/mobile/ui-mobile.controller.ts b/packages/sheets-ui/src/controllers/mobile/ui-mobile.controller.ts index 8626091d9ea3..bfbbb06e5410 100644 --- a/packages/sheets-ui/src/controllers/mobile/ui-mobile.controller.ts +++ b/packages/sheets-ui/src/controllers/mobile/ui-mobile.controller.ts @@ -91,7 +91,7 @@ import { SetZoomRatioOperation } from '../../commands/operations/set-zoom-ratio. import { SheetPermissionOpenDialogOperation } from '../../commands/operations/sheet-permission-open-dialog.operation'; import { SheetPermissionOpenPanelOperation } from '../../commands/operations/sheet-permission-open-panel.operation'; import { SidebarDefinedNameOperation } from '../../commands/operations/sidebar-defined-name.operation'; -import { menuSchema } from '../../menu/mobile-menu'; +import { menuSchema } from '../../menu/schema'; import { MobileSheetBar } from '../../views/mobile/sheet-bar/MobileSheetBar'; import { RenderSheetContent } from '../../views/sheet-container/SheetContainer'; diff --git a/packages/sheets-ui/src/controllers/render-controllers/__tests__/contextmenu-render-business.spec.ts b/packages/sheets-ui/src/controllers/render-controllers/__tests__/contextmenu-render-business.spec.ts index d7707b8e9d5e..8ac081642947 100644 --- a/packages/sheets-ui/src/controllers/render-controllers/__tests__/contextmenu-render-business.spec.ts +++ b/packages/sheets-ui/src/controllers/render-controllers/__tests__/contextmenu-render-business.spec.ts @@ -64,7 +64,8 @@ function createController(rangeType = RANGE_TYPE.NORMAL) { } as any, contextMenuService as any, { getCurrentSelections: vi.fn(() => [selection]) } as any, - { getSkeleton: vi.fn(() => skeleton) } as any + { getSkeleton: vi.fn(() => skeleton) } as any, + { has: vi.fn(() => false), get: vi.fn() } as any ); return { diff --git a/packages/sheets-ui/src/controllers/render-controllers/__tests__/contextmenu.render-controller.spec.ts b/packages/sheets-ui/src/controllers/render-controllers/__tests__/contextmenu.render-controller.spec.ts new file mode 100644 index 000000000000..962cd7b705b1 --- /dev/null +++ b/packages/sheets-ui/src/controllers/render-controllers/__tests__/contextmenu.render-controller.spec.ts @@ -0,0 +1,40 @@ +/** + * 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 { describe, expect, it } from 'vitest'; +import { ISheetHostChromeOverrideService } from '../../../services/sheet-host-chrome-override.service'; +import { shouldSuppressSheetContextMenuForEmbedOverride } from '../contextmenu.render-controller'; + +describe('SheetContextMenuRenderController embed chrome bridge', () => { + it('suppresses host sheet context menus only for active sheet-tab overrides', () => { + expect(shouldSuppressSheetContextMenuForEmbedOverride('host-1', { + hostUnitId: 'host-1', + entry: 'sheets-sheet-tab', + })).toBe(true); + expect(shouldSuppressSheetContextMenuForEmbedOverride('host-1', { + hostUnitId: 'host-1', + entry: 'sheets-floating-object', + })).toBe(false); + expect(shouldSuppressSheetContextMenuForEmbedOverride('host-1', { + hostUnitId: 'other-host', + entry: 'sheets-sheet-tab', + })).toBe(false); + }); + + it('uses a sheets-ui owned host chrome override service token', () => { + expect(ISheetHostChromeOverrideService).toBeTruthy(); + }); +}); diff --git a/packages/sheets-ui/src/controllers/render-controllers/__tests__/editor-bridge-render-business.spec.ts b/packages/sheets-ui/src/controllers/render-controllers/__tests__/editor-bridge-render-business.spec.ts index 66457f93dd06..2f6e137e41ec 100644 --- a/packages/sheets-ui/src/controllers/render-controllers/__tests__/editor-bridge-render-business.spec.ts +++ b/packages/sheets-ui/src/controllers/render-controllers/__tests__/editor-bridge-render-business.spec.ts @@ -14,8 +14,12 @@ * limitations under the License. */ +// @vitest-environment jsdom + +import type { EmbedRuntimeFocusCoordinator } from '../../../services/sheet-embed-integration.service'; import { DOCS_NORMAL_EDITOR_UNIT_ID_KEY, FOCUSING_FX_BAR_EDITOR, FOCUSING_SHEET } from '@univerjs/core'; import { DocSelectionRenderService } from '@univerjs/docs-ui'; +import { EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE } from '../../../services/sheet-embed-integration.service'; import { DeviceInputEventType } from '@univerjs/engine-render'; import { ClearSelectionFormatCommand, SetWorksheetActiveOperation } from '@univerjs/sheets'; import { Subject } from 'rxjs'; @@ -59,7 +63,17 @@ function createCommandService() { }; } -function createController(options?: { editorVisible?: boolean; forceKeepVisible?: boolean; focusedUnitId?: string }) { +function createController(options?: { + currentEditLocation?: { unitId: string; sheetId: string; row: number; column: number }; + currentEditCellState?: { documentLayoutObject?: { documentModel?: { getSnapshot?: () => { body?: { dataStream?: string } } } } }; + editorVisible?: boolean; + forceKeepVisible?: boolean; + focusedUnitId?: string; + isEmbedRuntimeEvent?: boolean; + isEmbedActiveSession?: boolean; + focusingSheet?: boolean; + isEmbedRuntimeEventImpl?: (unitId: string | undefined, target?: EventTarget | null, event?: Event) => boolean; +}) { const workbook$ = new Subject(); const selectionMoveEnd$ = new Subject(); const selectionMoveStart$ = new Subject(); @@ -83,9 +97,10 @@ function createController(options?: { editorVisible?: boolean; forceKeepVisible? workbook.getCurrentUnitOfType.mockReturnValue(workbook as any); const commandService = createCommandService(); const contextValues = new Map([ - [FOCUSING_SHEET, true], + [FOCUSING_SHEET, options?.focusingSheet ?? true], [FOCUSING_FX_BAR_EDITOR, false], ]); + const focusingSheet$ = new Subject(); const context = { unitId: 'unit-1', unit: workbook, @@ -110,6 +125,8 @@ function createController(options?: { editorVisible?: boolean; forceKeepVisible? : null), }; const editorBridgeService = { + getEditCellState: vi.fn(() => options?.currentEditCellState ?? null), + getEditLocation: vi.fn(() => options?.currentEditLocation ?? null), isVisible: vi.fn(() => ({ visible: options?.editorVisible ?? false })), isForceKeepVisible: vi.fn(() => options?.forceKeepVisible ?? false), refreshEditCellState: vi.fn(), @@ -121,6 +138,9 @@ function createController(options?: { editorVisible?: boolean; forceKeepVisible? getFocusedUnit: vi.fn(() => ({ getUnitId: () => options?.focusedUnitId ?? 'unit-1', })), + getUnit: vi.fn(() => ({ + getBody: () => ({ dataStream: '=\r\n' }), + })), } as any, commandService as any, editorBridgeService as any, @@ -144,6 +164,7 @@ function createController(options?: { editorVisible?: boolean; forceKeepVisible? { getContextValue: vi.fn((key: string) => contextValues.get(key)), setContextValue: vi.fn((key: string, value: unknown) => contextValues.set(key, value)), + subscribeContextValue$: vi.fn((key: string) => key === FOCUSING_SHEET ? focusingSheet$ : new Subject()), } as any, renderManagerService as any, { @@ -163,7 +184,11 @@ function createController(options?: { editorVisible?: boolean; forceKeepVisible? })), }, })), - } as any + } as any, + { + isChildUnitRuntimeEvent: vi.fn((unitId, target, event) => options?.isEmbedRuntimeEventImpl?.(unitId, target, event) ?? options?.isEmbedRuntimeEvent ?? false), + isChildUnitInActiveSession: vi.fn(() => options?.isEmbedActiveSession ?? false), + } as unknown as EmbedRuntimeFocusCoordinator ); workbook$.next(workbook); @@ -179,6 +204,7 @@ function createController(options?: { editorVisible?: boolean; forceKeepVisible? spreadsheetLeftTopPlaceholder, spreadsheetRowHeader, workbook, + workbook$, }; } @@ -220,6 +246,35 @@ describe('EditorBridgeRenderController business flows', () => { controller.dispose(); }); + it('does not reactivate the same edit cell for repeated selection sync events', () => { + const { commandService, controller, selectionMoveEnd$, selectionSet$ } = createController({ + currentEditLocation: { + unitId: 'unit-1', + sheetId: 'sheet-1', + row: 1, + column: 2, + }, + }); + + const selection = [{ + primary: { + actualRow: 3, + actualColumn: 4, + startRow: 3, + startColumn: 4, + endRow: 3, + endColumn: 4, + }, + }]; + + selectionSet$.next(selection); + selectionMoveEnd$.next(selection); + + expect(commandService.executeCommand).not.toHaveBeenCalledWith(SetActivateCellEditOperation.id, expect.anything()); + + controller.dispose(); + }); + it('opens sheet editing from double click and keyboard input, then refreshes a hidden editor after formatting commands', () => { const { commandService, controller, editorBridgeService, inputBefore$, spreadsheet } = createController(); @@ -240,6 +295,7 @@ describe('EditorBridgeRenderController business flows', () => { visible: true, eventType: DeviceInputEventType.Keyboard, keycode: 65, + initialValue: 'A', unitId: 'unit-1', }); @@ -250,6 +306,45 @@ describe('EditorBridgeRenderController business flows', () => { controller.dispose(); }); + it('opens sheet editing from keyboard input while the sheet is an active embed child session', () => { + const { commandService, controller, inputBefore$ } = createController({ + focusingSheet: false, + focusedUnitId: 'host-unit', + isEmbedActiveSession: true, + }); + + inputBefore$.next({ event: { data: '=', which: 187 } }); + + expect(commandService.syncExecuteCommand).toHaveBeenCalledWith(SetCellEditVisibleOperation.id, { + visible: true, + eventType: DeviceInputEventType.Keyboard, + keycode: 187, + initialValue: '=', + unitId: 'unit-1', + }); + + controller.dispose(); + }); + + it('does not stack keyboard input listeners when the current workbook emits repeatedly', () => { + const { commandService, controller, inputBefore$, workbook, workbook$ } = createController(); + + workbook$.next(workbook); + workbook$.next(workbook); + inputBefore$.next({ event: { data: '=', which: 187 } }); + + expect(commandService.syncExecuteCommand).toHaveBeenCalledTimes(1); + expect(commandService.syncExecuteCommand).toHaveBeenCalledWith(SetCellEditVisibleOperation.id, { + visible: true, + eventType: DeviceInputEventType.Keyboard, + keycode: 187, + initialValue: '=', + unitId: 'unit-1', + }); + + controller.dispose(); + }); + it('hides a visible editor from sheet pointer actions but keeps it while formula range input is active', () => { const keepVisible = createController({ editorVisible: true, forceKeepVisible: true }); keepVisible.spreadsheet.onPointerDown$.emit({}); @@ -278,4 +373,124 @@ describe('EditorBridgeRenderController business flows', () => { normal.controller.dispose(); }); + + it('keeps a visible embedded cell editor when pointer down originates inside the same embed owner', () => { + const normal = createController({ editorVisible: true }); + const embedRoot = document.createElement('div'); + const editorTarget = document.createElement('div'); + embedRoot.setAttribute(EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE, 'embed-1'); + editorTarget.setAttribute(EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE, 'embed-1'); + editorTarget.setAttribute('data-u-comp', 'editor'); + document.body.append(embedRoot, editorTarget); + + normal.spreadsheet.onPointerDown$.emit({ target: editorTarget }); + expect(normal.commandService.syncExecuteCommand).not.toHaveBeenCalled(); + + editorTarget.getBoundingClientRect = () => ({ + x: 100, + y: 200, + left: 100, + top: 200, + right: 180, + bottom: 224, + width: 80, + height: 24, + toJSON: () => {}, + }); + normal.spreadsheet.onPointerDown$.emit({ clientX: 120, clientY: 210 }); + expect(normal.commandService.syncExecuteCommand).not.toHaveBeenCalled(); + + normal.spreadsheet.onPointerDown$.emit({ target: document.createElement('div') }); + expect(normal.commandService.syncExecuteCommand).toHaveBeenCalledWith(SetCellEditVisibleOperation.id, { + visible: false, + eventType: DeviceInputEventType.PointerDown, + unitId: 'unit-1', + }); + + normal.controller.dispose(); + embedRoot.remove(); + editorTarget.remove(); + }); + + it('hides a visible embedded cell editor when a regular edit points at the child runtime canvas', () => { + const normal = createController({ editorVisible: true, isEmbedRuntimeEvent: true }); + const runtimeCanvas = document.createElement('canvas'); + + normal.spreadsheet.onPointerDown$.emit({ target: runtimeCanvas }); + + expect(normal.commandService.syncExecuteCommand).toHaveBeenCalledWith(SetCellEditVisibleOperation.id, { + visible: false, + eventType: DeviceInputEventType.PointerDown, + unitId: 'unit-1', + }); + + normal.controller.dispose(); + }); + + it('keeps a visible embedded cell editor when formula range selection points at the child runtime canvas', () => { + const normal = createController({ + editorVisible: true, + isEmbedRuntimeEvent: true, + isEmbedActiveSession: true, + currentEditCellState: { + documentLayoutObject: { + documentModel: { + getSnapshot: () => ({ body: { dataStream: '=SUM(' } }), + }, + }, + }, + }); + const runtimeCanvas = document.createElement('canvas'); + + normal.spreadsheet.onPointerDown$.emit({ target: runtimeCanvas }); + + expect(normal.commandService.syncExecuteCommand).not.toHaveBeenCalled(); + + normal.controller.dispose(); + }); + + it('passes engine pointer coordinates to embed runtime detection for formula range selection', () => { + const runtimeCanvas = document.createElement('canvas'); + const normal = createController({ + editorVisible: true, + isEmbedActiveSession: true, + currentEditCellState: { + documentLayoutObject: { + documentModel: { + getSnapshot: () => ({ body: { dataStream: '=SUM(' } }), + }, + }, + }, + isEmbedRuntimeEventImpl: (_unitId, _target, event) => ( + (event as unknown as { clientX?: number; clientY?: number })?.clientX === 120 && + (event as unknown as { clientX?: number; clientY?: number })?.clientY === 210 + ), + }); + + normal.spreadsheet.onPointerDown$.emit({ target: runtimeCanvas, clientX: 120, clientY: 210 }); + + expect(normal.commandService.syncExecuteCommand).not.toHaveBeenCalled(); + + normal.controller.dispose(); + }); + + it('keeps an embedded formula editor visible during worksheet activation without a pointer event', () => { + const normal = createController({ + editorVisible: true, + isEmbedActiveSession: true, + currentEditCellState: { + documentLayoutObject: { + documentModel: { + getSnapshot: () => ({ body: { dataStream: '=A1\r\n' } }), + }, + }, + }, + }); + + normal.commandService.emitBefore({ id: SetWorksheetActiveOperation.id }); + + expect(normal.commandService.syncExecuteCommand).not.toHaveBeenCalled(); + + normal.controller.dispose(); + }); }); diff --git a/packages/sheets-ui/src/controllers/render-controllers/__tests__/scroll.render-controller.spec.ts b/packages/sheets-ui/src/controllers/render-controllers/__tests__/scroll.render-controller.spec.ts index 18ce292222cd..f38c913fca01 100644 --- a/packages/sheets-ui/src/controllers/render-controllers/__tests__/scroll.render-controller.spec.ts +++ b/packages/sheets-ui/src/controllers/render-controllers/__tests__/scroll.render-controller.spec.ts @@ -204,9 +204,10 @@ describe('SheetsScrollRenderController', () => { const testBed = createRenderTestBed({ dependencies: [[SheetScrollManagerService, { useValue: scrollManagerService }]], }); - const { context, viewportMap } = testBed; + const { context, viewportMap, sheet } = testBed; const commandService = testBed.get(ICommandService); const executeSpy = vi.spyOn(commandService, 'executeCommand'); + const worksheet = sheet.getActiveSheet(); const controller = testBed.injector.createInstance(SheetsScrollRenderController, context as any); const viewMain = viewportMap.get(SHEET_VIEWPORT_KEY.VIEW_MAIN) as any; @@ -226,6 +227,8 @@ describe('SheetsScrollRenderController', () => { viewMain.onScrollByBar$.emit({ isTrigger: true, viewportScrollX: 250, viewportScrollY: 45 }, {}); expect(executeSpy).toHaveBeenCalledWith(ScrollCommand.id, { + unitId: sheet.getUnitId(), + sheetId: worksheet.getSheetId(), sheetViewStartRow: 2, sheetViewStartColumn: 2, offsetX: 50, @@ -264,6 +267,8 @@ describe('SheetsScrollRenderController', () => { }, {}); expect(executeSpy).toHaveBeenCalledWith(ScrollCommand.id, { + unitId: 'test', + sheetId: 'sheet1', sheetViewStartRow: 2, sheetViewStartColumn: 2, offsetX: 50, @@ -332,6 +337,8 @@ describe('SheetsScrollRenderController', () => { expect(controller.scrollToCell(5, 7, 300)).toBe(true); expect(syncSpy).toHaveBeenCalledWith(ScrollCommand.id, { + unitId: sheet.getUnitId(), + sheetId: worksheet.getSheetId(), sheetViewStartRow: 3, sheetViewStartColumn: 6, offsetX: 0, diff --git a/packages/sheets-ui/src/controllers/render-controllers/contextmenu.render-controller.ts b/packages/sheets-ui/src/controllers/render-controllers/contextmenu.render-controller.ts index 261d26889247..c43abea6d3ae 100644 --- a/packages/sheets-ui/src/controllers/render-controllers/contextmenu.render-controller.ts +++ b/packages/sheets-ui/src/controllers/render-controllers/contextmenu.render-controller.ts @@ -16,15 +16,18 @@ import type { Workbook } from '@univerjs/core'; import type { IRenderContext, IRenderModule, Spreadsheet, SpreadsheetColumnHeader, SpreadsheetHeader } from '@univerjs/engine-render'; +import type { ISheetHostChromeOverride } from '../../services/sheet-host-chrome-override.service'; import { Disposable, Inject, + Injector, RANGE_TYPE, } from '@univerjs/core'; import { attachSelectionWithCoord, SheetsSelectionsService } from '@univerjs/sheets'; import { ContextMenuPosition, IContextMenuService } from '@univerjs/ui'; import { SHEET_VIEW_KEY } from '../../common/keys'; import { ISheetSelectionRenderService } from '../../services/selection/base-selection-render.service'; +import { ISheetHostChromeOverrideService } from '../../services/sheet-host-chrome-override.service'; /** * This controller subscribe to context menu events in sheet rendering views and invoke context menu at a correct @@ -35,7 +38,8 @@ export class SheetContextMenuRenderController extends Disposable implements IRen private readonly _context: IRenderContext, @IContextMenuService private readonly _contextMenuService: IContextMenuService, @Inject(SheetsSelectionsService) private readonly _selectionManagerService: SheetsSelectionsService, - @ISheetSelectionRenderService private readonly _selectionRenderService: ISheetSelectionRenderService + @ISheetSelectionRenderService private readonly _selectionRenderService: ISheetSelectionRenderService, + @Inject(Injector) private readonly _injector: Injector ) { super(); @@ -74,6 +78,9 @@ export class SheetContextMenuRenderController extends Disposable implements IRen }; const triggerMenu = (position: string) => { + if (this._shouldSuppressHostContextMenu()) { + return; + } this._contextMenuService.triggerContextMenu(event, position); }; if (!isPointerInRange()) { @@ -94,6 +101,9 @@ export class SheetContextMenuRenderController extends Disposable implements IRen const spreadsheetRowHeader = this._context.components.get(SHEET_VIEW_KEY.ROW) as SpreadsheetHeader; const rowHeaderSub = spreadsheetRowHeader.onPointerDown$.subscribeEvent((event) => { if (event.button === 2) { + if (this._shouldSuppressHostContextMenu()) { + return; + } this._contextMenuService.triggerContextMenu(event, ContextMenuPosition.ROW_HEADER); } }); @@ -103,9 +113,32 @@ export class SheetContextMenuRenderController extends Disposable implements IRen const colHeaderPointerDownObserver = spreadsheetColumnHeader.onPointerDown$; const colHeaderObserver = colHeaderPointerDownObserver.subscribeEvent((event) => { if (event.button === 2) { + if (this._shouldSuppressHostContextMenu()) { + return; + } this._contextMenuService.triggerContextMenu(event, ContextMenuPosition.COL_HEADER); } }); this.disposeWithMe(colHeaderObserver); } + + private _shouldSuppressHostContextMenu(): boolean { + return shouldSuppressSheetContextMenuForEmbedOverride( + this._context.unitId, + this._getSheetHostChromeOverrideService()?.getOverride?.() + ); + } + + private _getSheetHostChromeOverrideService(): ISheetHostChromeOverrideService | undefined { + return this._injector.has(ISheetHostChromeOverrideService) + ? this._injector.get(ISheetHostChromeOverrideService) + : undefined; + } +} + +export function shouldSuppressSheetContextMenuForEmbedOverride( + hostUnitId: string, + override: Pick | null | undefined +): boolean { + return override != null && override.hostUnitId === hostUnitId && override.entry === 'sheets-sheet-tab'; } diff --git a/packages/sheets-ui/src/controllers/render-controllers/editor-bridge.render-controller.ts b/packages/sheets-ui/src/controllers/render-controllers/editor-bridge.render-controller.ts index a6fae4a3d33f..c80f6957fefa 100644 --- a/packages/sheets-ui/src/controllers/render-controllers/editor-bridge.render-controller.ts +++ b/packages/sheets-ui/src/controllers/render-controllers/editor-bridge.render-controller.ts @@ -19,7 +19,8 @@ import type { IEditorInputConfig } from '@univerjs/docs-ui'; import type { IRender, IRenderContext, IRenderModule } from '@univerjs/engine-render'; import type { ISelectionWithStyle } from '@univerjs/sheets'; import type { ICurrentEditCellParam, IEditorBridgeServiceVisibleParam } from '../../services/editor-bridge.service'; -import { DisposableCollection, DOCS_NORMAL_EDITOR_UNIT_ID_KEY, FOCUSING_FX_BAR_EDITOR, FOCUSING_SHEET, ICommandService, IContextService, Inject, IUniverInstanceService, RxDisposable, toDisposable, UniverInstanceType } from '@univerjs/core'; +import type { ISheetObjectParam } from '../utils/component-tools'; +import { DisposableCollection, DOCS_NORMAL_EDITOR_UNIT_ID_KEY, FOCUSING_FX_BAR_EDITOR, FOCUSING_SHEET, ICommandService, IContextService, Inject, IUniverInstanceService, Optional, RxDisposable, toDisposable, UniverInstanceType } from '@univerjs/core'; import { DocSelectionRenderService } from '@univerjs/docs-ui'; import { DeviceInputEventType, IRenderManagerService } from '@univerjs/engine-render'; import { @@ -32,6 +33,7 @@ import { SetZoomRatioCommand } from '../../commands/commands/set-zoom-ratio.comm import { SetActivateCellEditOperation } from '../../commands/operations/activate-cell-edit.operation'; import { SetCellEditVisibleOperation } from '../../commands/operations/cell-edit.operation'; import { IEditorBridgeService } from '../../services/editor-bridge.service'; +import { ISheetEmbedRuntimeFocusCoordinator, SHEET_EMBED_RUNTIME_FOCUS_ROLE_ATTRIBUTE } from '../../services/sheet-embed-integration.service'; import { SheetSkeletonManagerService } from '../../services/sheet-skeleton-manager.service'; import { getSheetObject } from '../utils/component-tools'; @@ -48,12 +50,16 @@ export class EditorBridgeRenderController extends RxDisposable implements IRende @Inject(SheetsSelectionsService) private readonly _selectionManagerService: SheetsSelectionsService, @IContextService private readonly _contextService: IContextService, @IRenderManagerService private readonly _renderManagerService: IRenderManagerService, - @Inject(SheetSkeletonManagerService) private readonly _sheetSkeletonManagerService: SheetSkeletonManagerService + @Inject(SheetSkeletonManagerService) private readonly _sheetSkeletonManagerService: SheetSkeletonManagerService, + @Optional(ISheetEmbedRuntimeFocusCoordinator) private readonly _embedRuntimeFocusCoordinator?: ISheetEmbedRuntimeFocusCoordinator ) { super(); this.disposeWithMe(this._instanceSrv.getCurrentTypeOfUnit$(UniverInstanceType.UNIVER_SHEET).subscribe((workbook) => { if (workbook && workbook.getUnitId() === this._context.unitId) { + if (this._d) { + return; + } this._d = this._init(); } else { this._disposeCurrent(); @@ -67,6 +73,7 @@ export class EditorBridgeRenderController extends RxDisposable implements IRende this._initEventListener(d); this._commandExecutedListener(d); this._initialKeyboardListener(d); + this._initSheetFocusListener(d); return d; } @@ -89,6 +96,9 @@ export class EditorBridgeRenderController extends RxDisposable implements IRende const primary = params?.[params.length - 1]?.primary; if (primary) { const sheetObject = this._getSheetObject(); + if (!sheetObject) { + return; + } const { scene, engine } = sheetObject; const unitId = this._context.unitId; const sheetId = this._context.unit.getActiveSheet()?.getSheetId(); @@ -106,6 +116,9 @@ export class EditorBridgeRenderController extends RxDisposable implements IRende isMergedMainCell: mergeInfo.isMergedMainCell, } : primary; + if (isSameEditCell(this._editorBridgeService.getEditLocation(), unitId, sheetId, newPrimary)) { + return; + } this._commandService.executeCommand(SetActivateCellEditOperation.id, { scene, engine, @@ -128,6 +141,9 @@ export class EditorBridgeRenderController extends RxDisposable implements IRende private _initEventListener(d: DisposableCollection) { const sheetObject = this._getSheetObject(); + if (!sheetObject) { + return; + } const { spreadsheet, spreadsheetColumnHeader, spreadsheetLeftTopPlaceholder, spreadsheetRowHeader } = sheetObject; d.add(spreadsheet.onDblclick$.subscribeEvent((evt) => { @@ -143,19 +159,19 @@ export class EditorBridgeRenderController extends RxDisposable implements IRende })); d.add(spreadsheet.onPointerDown$.subscribeEvent({ - next: this._tryHideEditor.bind(this), + next: (payload) => this._tryHideEditor(resolvePointerEventPayload(payload)), priority: -1, })); d.add(spreadsheetColumnHeader.onPointerDown$.subscribeEvent({ - next: this._tryHideEditor.bind(this), + next: (payload) => this._tryHideEditor(resolvePointerEventPayload(payload)), priority: -1, })); d.add(spreadsheetLeftTopPlaceholder.onPointerDown$.subscribeEvent({ - next: this._tryHideEditor.bind(this), + next: (payload) => this._tryHideEditor(resolvePointerEventPayload(payload)), priority: -1, })); d.add(spreadsheetRowHeader.onPointerDown$.subscribeEvent({ - next: this._tryHideEditor.bind(this), + next: (payload) => this._tryHideEditor(resolvePointerEventPayload(payload)), priority: -1, })); } @@ -174,7 +190,8 @@ export class EditorBridgeRenderController extends RxDisposable implements IRende return; } const isFocusFormulaEditor = this._contextService.getContextValue(FOCUSING_FX_BAR_EDITOR); - const isFocusSheets = this._contextService.getContextValue(FOCUSING_SHEET); + const isFocusSheets = this._contextService.getContextValue(FOCUSING_SHEET) || + this._embedRuntimeFocusCoordinator?.isChildUnitInActiveSession(this._context.unitId) === true; const unitId = render.unitId; if (this._editorBridgeService.isVisible().visible) return; if (unitId && isFocusSheets && !isFocusFormulaEditor) { @@ -197,6 +214,30 @@ export class EditorBridgeRenderController extends RxDisposable implements IRende } } + private _initSheetFocusListener(d: DisposableCollection) { + d.add(this._contextService.subscribeContextValue$(FOCUSING_SHEET).subscribe((isFocusingSheet) => { + if ( + !isFocusingSheet || + !this._isCurrentSheetFocused() || + this._contextService.getContextValue(FOCUSING_FX_BAR_EDITOR) || + this._editorBridgeService.isVisible().visible + ) { + return; + } + + this._focusCellEditorInput(); + })); + } + + private _focusCellEditorInput(): void { + const render = this._renderManagerService.getRenderById(DOCS_NORMAL_EDITOR_UNIT_ID_KEY); + const docSelectionRenderService = render?.with(DocSelectionRenderService); + + if (!docSelectionRenderService?.isFocusing) { + docSelectionRenderService?.focus(); + } + } + private _commandExecutedListener(d: DisposableCollection) { const refreshCommandSet = new Set([ClearSelectionFormatCommand.id, SetZoomRatioCommand.id]); d.add(this._commandService.onCommandExecuted((command: ICommandInfo) => { @@ -233,24 +274,53 @@ export class EditorBridgeRenderController extends RxDisposable implements IRende return; } + const initialValue = config.content ?? event.data ?? ''; this._commandService.syncExecuteCommand(SetCellEditVisibleOperation.id, { visible: true, eventType: DeviceInputEventType.Keyboard, keycode: event.which, + initialValue, unitId: this._context.unitId, }); } - private _tryHideEditor() { + private _tryHideEditor(evt?: Event | { target?: EventTarget | null; clientX?: number; clientY?: number; x?: number; y?: number }) { // In the activated state of formula editing, // prohibit closing the editor according to the state to facilitate generating selection reference text. if (this._editorBridgeService.isForceKeepVisible()) { return; } + if (!evt && this._isEmbeddedFormulaEditorActive()) { + return; + } + if (this._isEmbeddedFormulaEditorActive() && this._isCurrentEmbedRuntimeEvent(evt)) { + return; + } + if (isEmbedCellEditorInteraction(evt)) { + return; + } this._hideEditor(); } + private _isEmbeddedFormulaEditorActive(): boolean { + if (this._embedRuntimeFocusCoordinator?.isChildUnitInActiveSession(this._context.unitId) !== true) { + return false; + } + + const dataStream = this._editorBridgeService.getEditCellState()?.documentLayoutObject.documentModel?.getSnapshot().body?.dataStream; + + return typeof dataStream === 'string' && dataStream.startsWith('='); + } + + private _isCurrentEmbedRuntimeEvent(evt?: PointerEventLike): boolean { + return this._embedRuntimeFocusCoordinator?.isChildUnitRuntimeEvent( + this._context.unitId, + evt?.target, + evt instanceof Event ? evt : evt as Event | undefined + ) === true; + } + private _hideEditor() { if (this._editorBridgeService.isVisible().visible !== true) return; @@ -261,11 +331,91 @@ export class EditorBridgeRenderController extends RxDisposable implements IRende }); } - private _getSheetObject() { - return getSheetObject(this._context.unit, this._context)!; + private _getSheetObject(): Nullable { + if (!this._context.unit) { + return null; + } + + return getSheetObject(this._context.unit, this._context); } private _isCurrentSheetFocused(): boolean { - return this._instanceSrv.getFocusedUnit()?.getUnitId() === this._context.unitId; + return this._instanceSrv.getFocusedUnit()?.getUnitId() === this._context.unitId || + this._embedRuntimeFocusCoordinator?.isChildUnitInActiveSession(this._context.unitId) === true; + } +} + +type PointerEventLike = Event | { target?: EventTarget | null; clientX?: number; clientY?: number; x?: number; y?: number }; + +function resolvePointerEventPayload(payload: PointerEventLike | [PointerEventLike, unknown] | undefined): PointerEventLike | undefined { + return Array.isArray(payload) ? payload[0] : payload; +} + +function isEmbedCellEditorInteraction(evt: PointerEventLike | undefined): boolean { + return isEmbedCellEditorInteractionTarget(evt?.target) || isEmbedCellEditorInteractionPoint(resolvePointerEventPoint(evt)); +} + +function isEmbedCellEditorInteractionTarget(target: EventTarget | null | undefined): boolean { + if (typeof HTMLElement === 'undefined' || !(target instanceof HTMLElement)) { + return false; } + + return target.closest(`[${SHEET_EMBED_RUNTIME_FOCUS_ROLE_ATTRIBUTE}="child-editor"]`) != null || + target.closest('[data-u-comp="editor"]') != null || + target.closest('[id^="__editor___INTERNAL_EDITOR__"]') != null || + target.closest('[id^="univer-doc-selection-container-__INTERNAL_EDITOR__"]') != null; +} + +function resolvePointerEventPoint(evt: PointerEventLike | undefined): { clientX?: number; clientY?: number; x?: number; y?: number } | undefined { + if (!evt) { + return undefined; + } + + return { + clientX: 'clientX' in evt ? evt.clientX : undefined, + clientY: 'clientY' in evt ? evt.clientY : undefined, + x: 'x' in evt ? evt.x : undefined, + y: 'y' in evt ? evt.y : undefined, + }; +} + +function isEmbedCellEditorInteractionPoint(evt: { clientX?: number; clientY?: number; x?: number; y?: number } | undefined): boolean { + if (typeof document === 'undefined') { + return false; + } + + const clientX = Number.isFinite(evt?.clientX) ? evt?.clientX : evt?.x; + const clientY = Number.isFinite(evt?.clientY) ? evt?.clientY : evt?.y; + if (!Number.isFinite(clientX) || !Number.isFinite(clientY)) { + return false; + } + + const editorRoots = document.querySelectorAll([ + `[${SHEET_EMBED_RUNTIME_FOCUS_ROLE_ATTRIBUTE}="child-editor"]`, + '[data-u-comp="editor"]', + '[id^="__editor___INTERNAL_EDITOR__"]', + '[id^="univer-doc-selection-container-__INTERNAL_EDITOR__"]', + ].join(',')); + + return [...editorRoots].some((element) => { + const rect = element.getBoundingClientRect(); + return rect.width > 0 && + rect.height > 0 && + clientX! >= rect.left && + clientX! <= rect.right && + clientY! >= rect.top && + clientY! <= rect.bottom; + }); +} + +function isSameEditCell( + current: ReturnType, + unitId: string, + sheetId: string, + primary: ISelectionCell +): boolean { + return current?.unitId === unitId && + current.sheetId === sheetId && + current.row === primary.startRow && + current.column === primary.startColumn; } diff --git a/packages/sheets-ui/src/controllers/render-controllers/header-unhide.render-controller.ts b/packages/sheets-ui/src/controllers/render-controllers/header-unhide.render-controller.ts index 0fd8d5e05a76..d6b6e31c0440 100644 --- a/packages/sheets-ui/src/controllers/render-controllers/header-unhide.render-controller.ts +++ b/packages/sheets-ui/src/controllers/render-controllers/header-unhide.render-controller.ts @@ -39,7 +39,6 @@ import { getCoordByCell, getSheetObject } from '../utils/component-tools'; const HEADER_UNHIDE_CONTROLLER_SHAPE = '__SpreadsheetHeaderUnhideSHAPEControllerShape__'; -export type { IHeaderUnhideRangeVisibleCheck }; export const HEADER_UNHIDE_RANGE_VISIBLE_CHECK = createInterceptorKey('headerUnhideRangeVisibleCheck'); /** diff --git a/packages/sheets-ui/src/controllers/render-controllers/scroll.render-controller.ts b/packages/sheets-ui/src/controllers/render-controllers/scroll.render-controller.ts index bcab5aa83964..6cb5893fc75a 100644 --- a/packages/sheets-ui/src/controllers/render-controllers/scroll.render-controller.ts +++ b/packages/sheets-ui/src/controllers/render-controllers/scroll.render-controller.ts @@ -252,6 +252,8 @@ export class SheetsScrollRenderController extends Disposable implements IRenderM // NOT same as SetScrollRelativeCommand. that was exec in sheetRenderController this._commandService.executeCommand(ScrollCommand.id, { + unitId: this._context.unitId, + sheetId: skeleton.getLocation()[1], sheetViewStartRow: row, sheetViewStartColumn: column, offsetX: columnOffset, @@ -376,6 +378,8 @@ export class SheetsScrollRenderController extends Disposable implements IRenderM xSplit: freezeXSplit, } = worksheet.getFreeze(); return this._commandService.syncExecuteCommand(ScrollCommand.id, { + unitId: this._context.unitId, + sheetId: worksheet.getSheetId(), sheetViewStartRow: row - freezeYSplit, sheetViewStartColumn: column - freezeXSplit, offsetX: 0, @@ -662,6 +666,8 @@ export class SheetsScrollRenderController extends Disposable implements IRenderM } return this._commandService.syncExecuteCommand(ScrollCommand.id, { + unitId: this._context.unitId, + sheetId: worksheet.getSheetId(), // sheetViewStartRow & offsetX should never be undefined, it's rendering, there should always be a value! // sheetViewStartRow: forceTop ? Math.max(0, row - freezeYSplit) : ((startSheetViewRow ?? 0) - freezeYSplit), diff --git a/packages/sheets-ui/src/controllers/ui.controller.ts b/packages/sheets-ui/src/controllers/ui.controller.ts index 790c45b94913..32de97af77e4 100644 --- a/packages/sheets-ui/src/controllers/ui.controller.ts +++ b/packages/sheets-ui/src/controllers/ui.controller.ts @@ -16,15 +16,15 @@ import { Disposable, + DOCS_NORMAL_EDITOR_UNIT_ID_KEY, ICommandService, IConfigService, Inject, Injector, - IUniverInstanceService, UniverInstanceType, } from '@univerjs/core'; import { DocSelectionRenderService } from '@univerjs/docs-ui'; -import { getCurrentTypeOfRenderer, IRenderManagerService } from '@univerjs/engine-render'; +import { IRenderManagerService } from '@univerjs/engine-render'; import { SetBoldCommand, @@ -348,9 +348,8 @@ export class SheetUIController extends Disposable { this._layoutService.registerFocusHandler(UniverInstanceType.UNIVER_SHEET, (_unitId: string) => { // DEBT: `_unitId` is not used hence we cannot support Univer mode now const renderManagerService = this._injector.get(IRenderManagerService); - const instanceService = this._injector.get(IUniverInstanceService); - const currentEditorRender = getCurrentTypeOfRenderer(UniverInstanceType.UNIVER_DOC, instanceService, renderManagerService); - const docSelectionRenderService = currentEditorRender?.with(DocSelectionRenderService); + const cellEditorRender = renderManagerService.getRenderById(DOCS_NORMAL_EDITOR_UNIT_ID_KEY); + const docSelectionRenderService = cellEditorRender?.with(DocSelectionRenderService); docSelectionRenderService?.focus(); }) diff --git a/packages/sheets-ui/src/embed-tab-anchor.ts b/packages/sheets-ui/src/embed-tab-anchor.ts new file mode 100644 index 000000000000..1c1601fe911a --- /dev/null +++ b/packages/sheets-ui/src/embed-tab-anchor.ts @@ -0,0 +1,45 @@ +/** + * 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 type { IWorksheetData } from '@univerjs/core'; + +const EMBED_SHEETS_TAB_CUSTOM_KEY = 'UNIVER_EMBED_SHEETS_TAB'; + +interface IEmbedSheetsTabCustomData { + version: 1; + embedId: string; + hostAnchorId: string; +} + +export function getEmbedSheetsTabCustomData(snapshot: Pick): IEmbedSheetsTabCustomData | undefined { + const value = snapshot.custom?.[EMBED_SHEETS_TAB_CUSTOM_KEY]; + if (!isEmbedSheetsTabCustomData(value)) { + return undefined; + } + + return value; +} + +function isEmbedSheetsTabCustomData(value: unknown): value is IEmbedSheetsTabCustomData { + if (!value || typeof value !== 'object') { + return false; + } + + const candidate = value as Partial; + return candidate.version === 1 && + typeof candidate.embedId === 'string' && + typeof candidate.hostAnchorId === 'string'; +} diff --git a/packages/sheets-ui/src/facade/f-univer.ts b/packages/sheets-ui/src/facade/f-univer.ts index f2b6ae112f37..5d95080e07cb 100644 --- a/packages/sheets-ui/src/facade/f-univer.ts +++ b/packages/sheets-ui/src/facade/f-univer.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import type { DocumentDataModel, ICellCustomRender, IDisposable, Injector, Nullable } from '@univerjs/core'; +import type { DependencyIdentifier, DocumentDataModel, ICellCustomRender, IDisposable, Injector, Nullable } from '@univerjs/core'; import type { IRichTextEditingMutationParams } from '@univerjs/docs'; import type { IRender, @@ -796,7 +796,7 @@ export class FUniverSheetsUIMixin extends FUniver implements IFUniverSheetsUIMix combined$Disposable.dispose(); const scrollManagerService = sheetRenderUnit.with(SheetScrollManagerService); - const selectionService = sheetRenderUnit.with(SheetsSelectionsService); + const selectionService = this._tryGetRenderDependency(sheetRenderUnit, SheetsSelectionsService); // Register scroll event handler combined$Disposable.add( @@ -815,61 +815,63 @@ export class FUniverSheetsUIMixin extends FUniver implements IFUniverSheetsUIMix ); // Register selection event handlers - combined$Disposable.add( - this.registerEventHandler( - this.Event.SelectionMoveStart, - () => selectionService.selectionMoveStart$.subscribe((selections) => { - const eventParams: ISelectionEventParams = { - workbook, - worksheet: workbook.getActiveSheet(), - selections: selections?.map((s) => s.range) ?? [], - }; - this.fireEvent(this.Event.SelectionMoveStart, eventParams); - }) - ) - ); - - combined$Disposable.add( - this.registerEventHandler( - this.Event.SelectionMoving, - () => selectionService.selectionMoving$.subscribe((selections) => { - const eventParams: ISelectionEventParams = { - workbook, - worksheet: workbook.getActiveSheet(), - selections: selections?.map((s) => s.range) ?? [], - }; - this.fireEvent(this.Event.SelectionMoving, eventParams); - }) - ) - ); - - combined$Disposable.add( - this.registerEventHandler( - this.Event.SelectionMoveEnd, - () => selectionService.selectionMoveEnd$.subscribe((selections) => { - const eventParams: ISelectionEventParams = { - workbook, - worksheet: workbook.getActiveSheet(), - selections: selections?.map((s) => s.range) ?? [], - }; - this.fireEvent(this.Event.SelectionMoveEnd, eventParams); - }) - ) - ); - - combined$Disposable.add( - this.registerEventHandler( - this.Event.SelectionChanged, - () => selectionService.selectionChanged$.subscribe((selections) => { - const eventParams: ISelectionEventParams = { - workbook, - worksheet: workbook.getActiveSheet(), - selections: selections?.map((s) => s.range) ?? [], - }; - this.fireEvent(this.Event.SelectionChanged, eventParams); - }) - ) - ); + if (selectionService) { + combined$Disposable.add( + this.registerEventHandler( + this.Event.SelectionMoveStart, + () => selectionService.selectionMoveStart$.subscribe((selections) => { + const eventParams: ISelectionEventParams = { + workbook, + worksheet: workbook.getActiveSheet(), + selections: selections?.map((s) => s.range) ?? [], + }; + this.fireEvent(this.Event.SelectionMoveStart, eventParams); + }) + ) + ); + + combined$Disposable.add( + this.registerEventHandler( + this.Event.SelectionMoving, + () => selectionService.selectionMoving$.subscribe((selections) => { + const eventParams: ISelectionEventParams = { + workbook, + worksheet: workbook.getActiveSheet(), + selections: selections?.map((s) => s.range) ?? [], + }; + this.fireEvent(this.Event.SelectionMoving, eventParams); + }) + ) + ); + + combined$Disposable.add( + this.registerEventHandler( + this.Event.SelectionMoveEnd, + () => selectionService.selectionMoveEnd$.subscribe((selections) => { + const eventParams: ISelectionEventParams = { + workbook, + worksheet: workbook.getActiveSheet(), + selections: selections?.map((s) => s.range) ?? [], + }; + this.fireEvent(this.Event.SelectionMoveEnd, eventParams); + }) + ) + ); + + combined$Disposable.add( + this.registerEventHandler( + this.Event.SelectionChanged, + () => selectionService.selectionChanged$.subscribe((selections) => { + const eventParams: ISelectionEventParams = { + workbook, + worksheet: workbook.getActiveSheet(), + selections: selections?.map((s) => s.range) ?? [], + }; + this.fireEvent(this.Event.SelectionChanged, eventParams); + }) + ) + ); + } // for pro, in pro, life cycle & created$ is not same as univer sdk // if not clear sheetRenderUnit, that would cause event bind twice! sheetRenderUnit = null; @@ -877,6 +879,18 @@ export class FUniverSheetsUIMixin extends FUniver implements IFUniverSheetsUIMix })); } + private _tryGetRenderDependency(render: IRender, dependency: DependencyIdentifier): Nullable { + try { + return render.with(dependency); + } catch (error) { + if (error instanceof Error && (error.message.includes('DependencyNotFoundError') || error.message.includes('Cannot find'))) { + return null; + } + + throw error; + } + } + /** * @ignore */ diff --git a/packages/sheets-ui/src/index.ts b/packages/sheets-ui/src/index.ts index 6fbfebb2df34..1fe7fb3ddb55 100644 --- a/packages/sheets-ui/src/index.ts +++ b/packages/sheets-ui/src/index.ts @@ -113,7 +113,8 @@ export { SheetPermissionOpenPanelOperation } from './commands/operations/sheet-p export { SidebarDefinedNameOperation } from './commands/operations/sidebar-defined-name.operation'; export { EMBEDDING_FORMULA_EDITOR_COMPONENT_KEY, RANGE_SELECTOR_COMPONENT_KEY, SHEET_VIEW_KEY } from './common/keys'; export { getCellRealRange, getViewportByCell } from './common/utils'; -export { type IUniverSheetsUIConfig } from './config/config'; +export type { IUniverSheetsUIConfig } from './config/config'; +export { SHEETS_UI_PLUGIN_CONFIG_KEY } from './config/config'; export { UNIVER_SHEET_PERMISSION_USER_PART } from './consts/permission'; export { SHEET_UI_PLUGIN_NAME } from './consts/plugin-name'; export { SheetsUIPart } from './consts/ui-name'; @@ -250,6 +251,25 @@ export { SELECTION_SHAPE_DEPTH } from './services/selection/const'; export { SelectionControl, SelectionControl as SelectionShape } from './services/selection/selection-control'; export { SheetSelectionRenderService } from './services/selection/selection-render.service'; export { SelectionShapeExtension } from './services/selection/selection-shape-extension'; +export { + ISheetEmbedFloatingGeometryService, + ISheetEmbedInteractionBoundaryService, + ISheetEmbedRuntimeFocusCoordinator, + resolveActiveSheetEmbedRuntimeDomScope, + resolveSheetEmbedRuntimeDomScope, + SHEET_EMBED_CHILD_TYPE_ATTRIBUTE, + SHEET_EMBED_CHILD_UNIT_ID_ATTRIBUTE, + SHEET_EMBED_FLOAT_DOM_ATTRIBUTE, + SHEET_EMBED_HOST_UNIT_ID_ATTRIBUTE, + SHEET_EMBED_ID_ATTRIBUTE, + SHEET_EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE, + SHEET_EMBED_RUNTIME_FOCUS_ROLE_ATTRIBUTE, +} from './services/sheet-embed-integration.service'; +export type { ISheetEmbedRuntimeDomScope } from './services/sheet-embed-integration.service'; +export { ISheetEmbedRuntimeService } from './services/sheet-embed-runtime.service'; +export type { ISheetEmbedTabMountParams } from './services/sheet-embed-runtime.service'; +export { ISheetHostChromeOverrideService } from './services/sheet-host-chrome-override.service'; +export type { ISheetHostChromeOverride } from './services/sheet-host-chrome-override.service'; export { SheetSkeletonManagerService } from './services/sheet-skeleton-manager.service'; export { SheetsRenderService } from './services/sheets-render.service'; export { IStatusBarService, StatusBarService } from './services/status-bar.service'; @@ -258,10 +278,16 @@ export { getCustomRangePosition, getEditingCustomRangePosition, } from './services/utils/doc-skeleton-util'; -export { useKeyEventConfig } from './views/editor-container'; +export { AutoFillPopupMenu } from './views/auto-fill-popup-menu/AutoFillPopupMenu'; +export { BorderLine } from './views/border-panel/border-line/BorderLine'; +export { BORDER_LINE_CHILDREN, BORDER_SIZE_CHILDREN } from './views/border-panel/interface'; +export { EditorContainer, useKeyEventConfig } from './views/editor-container'; +export { FormulaBar } from './views/formula-bar'; export { useActiveWorkbook, useActiveWorksheet, useWorkbooks } from './views/hook'; export type { IRangeProtectionRenderCellData } from './views/permission/extensions/range-protection.render'; export { type IPermissionDetailUserPartProps } from './views/permission/panel-detail/PermissionDetailUserPart'; export { type IBaseSheetBarProps } from './views/sheet-bar/sheet-bar-tabs/SheetBarItem'; +export { SheetBar } from './views/sheet-bar/SheetBar'; +export { SHEET_FOOTER_BAR_HEIGHT } from './views/sheet-container/SheetContainer'; export { type IStatisticItem } from './views/status-bar/CopyableStatisticItem'; export { functionDisplayNames } from './views/status-bar/CopyableStatisticItem'; diff --git a/packages/sheets-ui/src/menu/__tests__/menu.spec.ts b/packages/sheets-ui/src/menu/__tests__/menu.spec.ts deleted file mode 100644 index 8bd314cdce89..000000000000 --- a/packages/sheets-ui/src/menu/__tests__/menu.spec.ts +++ /dev/null @@ -1,95 +0,0 @@ -/** - * 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 type { IRange, Univer } from '@univerjs/core'; -import { - DisposableCollection, - ICommandService, - Injector, - RANGE_TYPE, -} from '@univerjs/core'; -import { - SetBoldCommand, - SetRangeValuesMutation, - SetStyleCommand, - SheetsSelectionsService, -} from '@univerjs/sheets'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { BoldMenuItemFactory } from '../menu'; -import { createMenuTestBed } from './create-menu-test-bed'; - -describe('test menu items', () => { - let univer: Univer; - let get: Injector['get']; - let commandService: ICommandService; - let disposableCollection: DisposableCollection; - - beforeEach(() => { - const testBed = createMenuTestBed(); - - univer = testBed.univer; - get = testBed.get; - - commandService = get(ICommandService); - commandService.registerCommand(SetBoldCommand); - commandService.registerCommand(SetStyleCommand); - commandService.registerCommand(SetRangeValuesMutation); - - disposableCollection = new DisposableCollection(); - }); - - afterEach(() => { - univer.dispose(); - - disposableCollection.dispose(); - }); - - function select(range: IRange) { - const selectionManager = get(SheetsSelectionsService); - - const { startColumn, startRow, endColumn, endRow } = range; - selectionManager.addSelections([ - { - range: { startRow, startColumn, endColumn, endRow, rangeType: RANGE_TYPE.NORMAL }, - primary: { - startRow, - startColumn, - endColumn, - endRow, - actualRow: startRow, - actualColumn: startColumn, - isMerged: false, - isMergedMainCell: false, - }, - style: null, - }, - ]); - } - - it('should "BoldMenu" change status correctly', async () => { - let activated = false; - let disabled = false; - const menuItem = get(Injector).invoke(BoldMenuItemFactory); - disposableCollection.add(menuItem.activated$!.subscribe((v: boolean) => (activated = v))); - disposableCollection.add(menuItem.disabled$!.subscribe((v: boolean) => (disabled = v))); - expect(activated).toBeFalsy(); - - select({ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }); - expect(await commandService.executeCommand(SetBoldCommand.id)).toBeTruthy(); - expect(activated).toBe(true); - expect(disabled).toBeFalsy(); - }); -}); diff --git a/packages/sheets-ui/src/mobile-plugin.ts b/packages/sheets-ui/src/mobile-plugin.ts index 0c7c00cbd741..906b79531fbb 100644 --- a/packages/sheets-ui/src/mobile-plugin.ts +++ b/packages/sheets-ui/src/mobile-plugin.ts @@ -284,6 +284,14 @@ export class UniverSheetsMobileUIPlugin extends Plugin { const univerInstanceService = this._univerInstanceService; this.disposeWithMe(univerInstanceService.getCurrentTypeOfUnit$(UniverInstanceType.UNIVER_SHEET) .pipe(filter((v) => !!v)) - .subscribe((workbook) => univerInstanceService.focusUnit(workbook!.getUnitId()))); + .subscribe((workbook) => { + const unitId = workbook!.getUnitId(); + const createOptions = univerInstanceService.getUnitCreateOptions(unitId); + if (createOptions?.makeCurrent === false) { + return; + } + + univerInstanceService.focusUnit(unitId); + })); } } diff --git a/packages/sheets-ui/src/plugin.ts b/packages/sheets-ui/src/plugin.ts index 13b9425d2852..4f04328b61bc 100644 --- a/packages/sheets-ui/src/plugin.ts +++ b/packages/sheets-ui/src/plugin.ts @@ -308,6 +308,14 @@ export class UniverSheetsUIPlugin extends Plugin { const univerInstanceService = this._univerInstanceService; this.disposeWithMe(univerInstanceService.getCurrentTypeOfUnit$(UniverInstanceType.UNIVER_SHEET) .pipe(filter((v) => !!v)) - .subscribe((workbook) => univerInstanceService.focusUnit(workbook!.getUnitId()))); + .subscribe((workbook) => { + const unitId = workbook!.getUnitId(); + const createOptions = univerInstanceService.getUnitCreateOptions(unitId); + if (createOptions?.makeCurrent === false) { + return; + } + + univerInstanceService.focusUnit(unitId); + })); } } diff --git a/packages/sheets-ui/src/services/__tests__/canvas-pop-manager.service.spec.ts b/packages/sheets-ui/src/services/__tests__/canvas-pop-manager.service.spec.ts index b455b8a30e27..7cd91e64b111 100644 --- a/packages/sheets-ui/src/services/__tests__/canvas-pop-manager.service.spec.ts +++ b/packages/sheets-ui/src/services/__tests__/canvas-pop-manager.service.spec.ts @@ -103,6 +103,7 @@ class TestCommandService { class TestUniverInstanceService { workbook: any; + embeddedUnitIds = new Set(); getCurrentUnitOfType() { return this.workbook; @@ -111,6 +112,10 @@ class TestUniverInstanceService { getUnit(unitId: string) { return this.workbook?.getUnitId() === unitId ? this.workbook : null; } + + getUnitCreateOptions(unitId: string) { + return this.embeddedUnitIds.has(unitId) ? { embeddedRender: true } : undefined; + } } class TestRenderManagerService { @@ -213,6 +218,7 @@ function createSheetHarness() { }; const sheetSelectionRenderService = { selectionMoving: false }; const render = { + getInjector: vi.fn(() => injector), scene: { getAncestorScale: () => ({ scaleX: 1, scaleY: 1 }), getViewport: () => ({ @@ -379,6 +385,20 @@ describe('SheetCanvasPopManagerService', () => { expect(popupService.removedIds).toEqual(['popup-1']); }); + it('uses a scoped popup injector only for embedded sheet render units', () => { + const { service, popupService, render, univerInstanceService } = createSheetHarness(); + const bound = { top: 10, left: 20, right: 30, bottom: 40 }; + + service.attachPopupToAbsolutePosition(bound, { componentKey: 'normal-popup' } as never, 'unit-1', 'sheet-1'); + expect(popupService.lastPopup()?.connectorInjector).toBeUndefined(); + expect(render.getInjector).not.toHaveBeenCalled(); + + univerInstanceService.embeddedUnitIds.add('unit-1'); + service.attachPopupToAbsolutePosition(bound, { componentKey: 'embed-popup' } as never, 'unit-1', 'sheet-1'); + expect(popupService.lastPopup()?.connectorInjector).toBeDefined(); + expect(render.getInjector).toHaveBeenCalledTimes(1); + }); + it('anchors a popup to a sheet position and refreshes its hidden freeze area on viewport changes', () => { const { service, popupService, commandService, transformChange$ } = createSheetHarness(); @@ -440,6 +460,19 @@ describe('SheetCanvasPopManagerService', () => { expect(disposable?.canDispose()).toBe(true); }); + it('disposes tracked cell popups when the manager is disposed', () => { + const { service, popupService, refRangeService, viewport } = createSheetHarness(); + + service.attachPopupToCell(1, 1, { componentKey: 'cell-action' } as never, 'unit-1', 'sheet-1', viewport as never); + + expect(popupService.popups.size).toBe(1); + service.dispose(); + + expect(popupService.removedIds).toEqual(['popup-1']); + expect(popupService.popups.size).toBe(0); + expect(refRangeService.watchedRanges[0].disposed).toBe(true); + }); + it('updates cell and range anchors when sheet geometry changes', () => { const { service, popupService, commandService, clientRect$, transformChange$, refRangeService, viewport } = createSheetHarness(); diff --git a/packages/sheets-ui/src/services/__tests__/cell-popup-manager.service.spec.ts b/packages/sheets-ui/src/services/__tests__/cell-popup-manager.service.spec.ts index 0277009708b8..a38bc05a69d9 100644 --- a/packages/sheets-ui/src/services/__tests__/cell-popup-manager.service.spec.ts +++ b/packages/sheets-ui/src/services/__tests__/cell-popup-manager.service.spec.ts @@ -97,4 +97,33 @@ describe('CellPopupManagerService', () => { expect(directions.includes('horizontal')).toBe(true); expect(directions.includes('vertical')).toBe(true); }); + + it('clears all popups for a workbook unit', () => { + const disposables = [{ dispose: vi.fn() }, { dispose: vi.fn() }]; + const sheetPopupService = { + attachPopupToCell: vi + .fn() + .mockReturnValueOnce(disposables[0]) + .mockReturnValueOnce(disposables[1]), + }; + const service = new CellPopupManagerService(sheetPopupService as any); + + service.showPopup({ unitId: 'unit-3', subUnitId: 'sheet-a', row: 1, col: 1 }, { + id: 'a', + componentKey: 'a', + priority: 1, + } as any); + service.showPopup({ unitId: 'unit-3', subUnitId: 'sheet-b', row: 2, col: 2 }, { + id: 'b', + componentKey: 'b', + priority: 1, + } as any); + + service.hidePopupsForUnit('unit-3'); + + expect(disposables[0].dispose).toHaveBeenCalledTimes(1); + expect(disposables[1].dispose).toHaveBeenCalledTimes(1); + expect(service.getPopups('unit-3', 'sheet-a', 1, 1, 'horizontal')).toEqual([]); + expect(service.getPopups('unit-3', 'sheet-b', 2, 2, 'horizontal')).toEqual([]); + }); }); diff --git a/packages/sheets-ui/src/services/__tests__/editor-bridge.service.spec.ts b/packages/sheets-ui/src/services/__tests__/editor-bridge.service.spec.ts index 6cd6f112856b..307217839f88 100644 --- a/packages/sheets-ui/src/services/__tests__/editor-bridge.service.spec.ts +++ b/packages/sheets-ui/src/services/__tests__/editor-bridge.service.spec.ts @@ -14,12 +14,29 @@ * limitations under the License. */ +/** + * 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 { DOCS_NORMAL_EDITOR_UNIT_ID_KEY, IContextService, Injector, IUniverInstanceService, ThemeService, + UniverInstanceType, } from '@univerjs/core'; import { IEditorService } from '@univerjs/docs-ui'; import { DeviceInputEventType, IRenderManagerService } from '@univerjs/engine-render'; @@ -52,7 +69,10 @@ function createService(options?: { hasFocusEditor?: boolean }) { }, univerInstanceService: { getTypeOfUnitDisposed$: vi.fn(() => unitDisposed$.asObservable()), - getCurrentUnitOfType: vi.fn(() => workbook), + getCurrentUnitOfType: vi.fn((_type?: UniverInstanceType) => workbook), + getUnit: vi.fn((unitId: string, type?: UniverInstanceType) => unitId === 'unit-1' + ? mocks.univerInstanceService.getCurrentUnitOfType(type as never) + : null), }, editorService: { getFocusEditor: vi.fn(() => (options?.hasFocusEditor ? { id: 'existing' } : null)), @@ -82,6 +102,7 @@ function createService(options?: { hasFocusEditor?: boolean }) { class TestUniverInstanceService { getTypeOfUnitDisposed$ = mocks.univerInstanceService.getTypeOfUnitDisposed$; getCurrentUnitOfType = mocks.univerInstanceService.getCurrentUnitOfType; + getUnit = mocks.univerInstanceService.getUnit; } class TestEditorService { @@ -317,4 +338,63 @@ describe('EditorBridgeService', () => { service.refreshEditCellPosition(true); expect(service.getEditCellLayout()?.position.startX).toBeGreaterThan(0); }); + + it('builds and refreshes the edit cell state from the target unit when current sheet unit is different', () => { + const { service, mocks } = createService(); + const documentModel = { + documentStyle: { + renderConfig: {}, + }, + getBody: () => ({ dataStream: 'Embedded\r\n', textRuns: [] }), + setZoomRatio: vi.fn(), + }; + const worksheet = { + getSheetId: () => 'sheet-1', + getCellRaw: vi.fn(() => ({ v: 'Embedded' })), + getCell: vi.fn(() => ({ v: 'Embedded' })), + getCellDocumentModelWithFormula: vi.fn(() => ({ documentModel })), + getBlankCellDocumentModel: vi.fn(() => ({ documentModel })), + }; + const childWorkbook = { + getUnitId: () => 'unit-1', + getActiveSheet: () => worksheet, + }; + mocks.univerInstanceService.getCurrentUnitOfType.mockReturnValue({ + getUnitId: () => 'host-or-other-sheet', + getActiveSheet: () => null, + } as never); + mocks.univerInstanceService.getUnit.mockImplementation((unitId: string, type?: UniverInstanceType) => ( + unitId === 'unit-1' && type === UniverInstanceType.UNIVER_SHEET ? childWorkbook : null + ) as never); + mocks.sheetSkeletonService.getSkeleton.mockReturnValue({ + getNoMergeCellWithCoordByIndex: (row: number, column: number) => ({ + startX: column * 80, + startY: row * 24, + endX: column * 80 + 80, + endY: row * 24 + 24, + }), + } as never); + mocks.renderManagerService.getRenderUnitById.mockReturnValue({ + with: () => ({ + getViewPort: () => ({ viewportKey: 'embedded-main' }), + }), + } as never); + + service.setEditCell(createPositionedEditCellParam()); + + expect(service.getEditLocation()).toEqual(expect.objectContaining({ + unitId: 'unit-1', + sheetId: 'sheet-1', + row: 1, + column: 2, + })); + expect(service.getEditCellLayout()).toEqual(expect.objectContaining({ + canvasOffset: { left: 12, top: 18 }, + scaleX: 2, + scaleY: 1.5, + })); + + service.refreshEditCellPosition(); + expect(service.getEditCellLayout()?.position.startX).toBeGreaterThan(0); + }); }); diff --git a/packages/sheets-ui/src/services/__tests__/sheet-skeleton-manager.service.spec.ts b/packages/sheets-ui/src/services/__tests__/sheet-skeleton-manager.service.spec.ts index 620c796c9718..d8058ff10fdb 100644 --- a/packages/sheets-ui/src/services/__tests__/sheet-skeleton-manager.service.spec.ts +++ b/packages/sheets-ui/src/services/__tests__/sheet-skeleton-manager.service.spec.ts @@ -161,4 +161,51 @@ describe('SheetSkeletonManagerService', () => { expect(resetSelections).toEqual([selections, selections]); expect((param as { commandId?: string }).commandId).toBe('sheet.command.set-row-header-width'); }); + + it('keeps header size updates safe when selection services are unavailable', () => { + const skeleton = { + columnHeaderHeight: 20, + rowHeaderWidth: 46, + registerGetCellHeight: () => {}, + }; + const param = { unitId: 'unit-1', sheetId: 'sheet-1', skeleton, dirty: false }; + TestSheetSkeletonService.skeleton = skeleton; + TestSheetSkeletonService.param = param; + + const injector = new Injector(); + injector.add([SheetSkeletonService, { useClass: TestSheetSkeletonService as never }]); + const service = injector.createInstance(SheetSkeletonManagerService, { + unit: {}, + unitId: 'unit-1', + type: UniverInstanceType.UNIVER_SHEET, + scene: {}, + } as never); + + const viewportState = new Map([ + [SHEET_VIEWPORT_KEY.VIEW_COLUMN_RIGHT, { left: 100, setViewportSize(params: object) { Object.assign(this, params); } }], + [SHEET_VIEWPORT_KEY.VIEW_COLUMN_LEFT, { setViewportSize(params: object) { Object.assign(this, params); } }], + [SHEET_VIEWPORT_KEY.VIEW_ROW_BOTTOM, { setViewportSize(params: object) { Object.assign(this, params); } }], + [SHEET_VIEWPORT_KEY.VIEW_ROW_TOP, { setViewportSize(params: object) { Object.assign(this, params); } }], + [SHEET_VIEWPORT_KEY.VIEW_LEFT_TOP, { width: 46, setViewportSize(params: object) { Object.assign(this, params); } }], + ]); + const mainViewport = { top: 0, left: 46 }; + const render = { + unitId: 'unit-1', + scene: { + getViewports: () => [mainViewport], + getViewport: (key: string) => viewportState.get(key), + }, + with: () => { + throw new Error('[redi]: Cannot find "SheetsSelectionsService" registered by any injector.'); + }, + }; + + expect(() => service.setColumnHeaderSize(render as never, 'sheet-1', 32)).not.toThrow(); + expect(() => service.setRowHeaderSize(render as never, 'sheet-1', 60)).not.toThrow(); + + expect(skeleton.columnHeaderHeight).toBe(32); + expect(skeleton.rowHeaderWidth).toBe(60); + expect(mainViewport).toEqual({ top: 32, left: 60 }); + expect((param as { commandId?: string }).commandId).toBe('sheet.command.set-row-header-width'); + }); }); diff --git a/packages/sheets-ui/src/services/__tests__/sheets-render.service.spec.ts b/packages/sheets-ui/src/services/__tests__/sheets-render.service.spec.ts index 13b05e3c8a2d..365cb5504f8d 100644 --- a/packages/sheets-ui/src/services/__tests__/sheets-render.service.spec.ts +++ b/packages/sheets-ui/src/services/__tests__/sheets-render.service.spec.ts @@ -52,11 +52,17 @@ function createLifecycleService() { const disposed$ = new Subject>(); const rawFormula$ = new BehaviorSubject(false); const initialWorkbook = createWorkbook('book-1'); - const created$ = new Subject(); - const createdRenderers: Array<{ unitId: string; options?: unknown }> = []; + const created$ = new Subject(); + const createdRenderers: Array<{ + unitId: string; + options?: unknown; + canvasElement: { dataset: Record }; + canvas: { setId: ReturnType; getCanvasEle: ReturnType }; + context: { setId: ReturnType }; + }> = []; const removedRenderers: string[] = []; - const spreadsheet = Object.create(Spreadsheet.prototype) as Spreadsheet & { makeForceDirty: any }; - spreadsheet.makeForceDirty = vi.fn(); + const spreadsheet = Object.create(Spreadsheet.prototype) as Spreadsheet & { makeForceDirty: (state?: boolean) => void }; + spreadsheet.makeForceDirty = vi.fn<(state?: boolean) => void>(); class TestContextService { subscribeContextValue$() { @@ -73,6 +79,10 @@ function createLifecycleService() { return type === UniverInstanceType.UNIVER_SHEET ? [initialWorkbook] : []; } + getUnitCreateOptions() { + return undefined; + } + getTypeOfUnitDisposed$(type: UniverInstanceType) { return type === UniverInstanceType.UNIVER_SHEET ? disposed$.asObservable() : new Subject().asObservable(); } @@ -82,7 +92,20 @@ function createLifecycleService() { readonly created$ = created$.asObservable(); createRender(unitId: string, options?: unknown) { - createdRenderers.push({ unitId, options }); + const canvasElement = { dataset: {} as Record }; + const canvas = { setId: vi.fn(), getCanvasEle: vi.fn(() => canvasElement) }; + const context = { setId: vi.fn() }; + createdRenderers.push({ unitId, options, canvasElement, canvas, context }); + return { + unitId, + engine: { + getCanvas: () => ({ + setId: canvas.setId, + getCanvasEle: canvas.getCanvasEle, + getContext: () => context, + }), + }, + }; } removeRender(unitId: string) { @@ -130,29 +153,20 @@ describe('SheetsRenderService', () => { }); it('creates and disposes sheet renderers as workbooks enter and leave the instance service', async () => { - const { added$, disposed$, created$, createdRenderers, removedRenderers } = createLifecycleService(); + const { added$, disposed$, createdRenderers, removedRenderers } = createLifecycleService(); await Promise.resolve(); expect(createdRenderers.map(({ unitId }) => unitId)).toEqual(['book-1']); added$.next({ unit: createWorkbook('book-2'), options: { mountContainer: 'container' } }); - expect(createdRenderers.at(-1)).toEqual({ + expect(createdRenderers.at(-1)).toMatchObject({ unitId: 'book-2', options: { mountContainer: 'container' }, }); - const canvas = { setId: vi.fn() }; - const context = { setId: vi.fn() }; - created$.next({ - unitId: 'book-2', - engine: { - getCanvas: () => ({ - setId: canvas.setId, - getContext: () => context, - }), - }, - }); + const { canvasElement, canvas, context } = createdRenderers.at(-1)!; expect(canvas.setId).toHaveBeenCalledWith('univer-sheet-main-canvas_book-2'); + expect(canvasElement.dataset.uUnitId).toBe('book-2'); expect(context.setId).toHaveBeenCalledWith('univer-sheet-main-canvas_book-2'); disposed$.next(createWorkbook('book-1')); diff --git a/packages/sheets-ui/src/services/canvas-pop-manager.service.ts b/packages/sheets-ui/src/services/canvas-pop-manager.service.ts index 0af52b722054..f40a737dae7f 100644 --- a/packages/sheets-ui/src/services/canvas-pop-manager.service.ts +++ b/packages/sheets-ui/src/services/canvas-pop-manager.service.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import type { DrawingTypeEnum, ICommandInfo, INeedCheckDisposable, IRange, Nullable, Workbook, Worksheet } from '@univerjs/core'; +import type { DrawingTypeEnum, ICommandInfo, IDisposable, INeedCheckDisposable, Injector, IRange, Nullable, Workbook, Worksheet } from '@univerjs/core'; import type { BaseObject, IBoundRectNoAngle, IRender, IShapeProps, Shape, SpreadsheetSkeleton, Viewport } from '@univerjs/engine-render'; import type { ISetWorksheetRowAutoHeightMutationParams, ISheetLocationBase } from '@univerjs/sheets'; import type { IPopup } from '@univerjs/ui'; @@ -49,6 +49,8 @@ export class SheetCanvasPopManagerService extends Disposable { // the DrawingTypeEnum should refer from drawing package, here we just use type, so no need to import the drawing package private _popupMenuFeatureMap = new Map(); private _popupMenuOffsetMap = new Map(); + private readonly _popupDisposables = new Set(); + constructor( @Inject(ICanvasPopupService) private readonly _globalPopupManagerService: ICanvasPopupService, @IRenderManagerService private readonly _renderManagerService: IRenderManagerService, @@ -122,6 +124,8 @@ export class SheetCanvasPopManagerService extends Disposable { } override dispose(): void { + Array.from(this._popupDisposables).forEach((disposable) => disposable.dispose()); + this._popupDisposables.clear(); super.dispose(); this._popupMenuFeatureMap.clear(); this._popupMenuOffsetMap.clear(); @@ -306,22 +310,27 @@ export class SheetCanvasPopManagerService extends Disposable { }; const { position, position$, disposable } = this._createPositionObserver(bound, currentRender, skeleton, worksheet); + const popupInjector = this._resolveEmbeddedPopupInjector(unitId, currentRender); const id = this._globalPopupManagerService.addPopup({ ...popup, unitId, subUnitId, + connectorInjector: popupInjector, anchorRect: position, anchorRect$: position$, canvasElement: currentRender.engine.getCanvasElement(), }); + const disposableCollection = new DisposableCollection(); + disposableCollection.add(disposable); + disposableCollection.add(toDisposable(() => { + this._globalPopupManagerService.removePopup(id); + position$.complete(); + })); + const trackedDisposable = this._trackPopupDisposable(disposableCollection); return { - dispose: () => { - this._globalPopupManagerService.removePopup(id); - position$.complete(); - disposable.dispose(); - }, + dispose: () => trackedDisposable.dispose(), canDispose: () => this._globalPopupManagerService.activePopupId !== id, }; } @@ -360,27 +369,32 @@ export class SheetCanvasPopManagerService extends Disposable { skeleton, currentRender, }); + const popupInjector = this._resolveEmbeddedPopupInjector(unitId, currentRender); const id = this._globalPopupManagerService.addPopup({ ...popup, unitId, subUnitId, + connectorInjector: popupInjector, anchorRect: position, anchorRect$: position$, hiddenRects$: rects$, canvasElement: currentRender.engine.getCanvasElement(), }); + const disposableCollection = new DisposableCollection(); + disposableCollection.add(disposable); + disposableCollection.add(rectsObserverDisposable); + disposableCollection.add(toDisposable(() => { + this._globalPopupManagerService.removePopup(id); + position$.complete(); + //@ts-ignore + workbook = null; + //@ts-ignore + worksheet = null; + })); + const trackedDisposable = this._trackPopupDisposable(disposableCollection); return { - dispose: () => { - this._globalPopupManagerService.removePopup(id); - position$.complete(); - disposable.dispose(); - rectsObserverDisposable.dispose(); - //@ts-ignore - workbook = null; - //@ts-ignore - worksheet = null; - }, + dispose: () => trackedDisposable.dispose(), canDispose: () => this._globalPopupManagerService.activePopupId !== id, }; } @@ -439,20 +453,25 @@ export class SheetCanvasPopManagerService extends Disposable { return; } + const popupInjector = this._resolveEmbeddedPopupInjector(unitId, currentRender); const id = this._globalPopupManagerService.addPopup({ ...popup, unitId, subUnitId, + connectorInjector: popupInjector, anchorRect: bound, anchorRect$, canvasElement: currentRender.engine.getCanvasElement(), }); + const disposableCollection = new DisposableCollection(); + disposableCollection.add(toDisposable(() => { + this._globalPopupManagerService.removePopup(id); + onDispose?.(); + })); + const trackedDisposable = this._trackPopupDisposable(disposableCollection); return { - dispose: () => { - this._globalPopupManagerService.removePopup(id); - onDispose?.(); - }, + dispose: () => trackedDisposable.dispose(), canDispose: () => this._globalPopupManagerService.activePopupId !== id, }; } @@ -509,10 +528,12 @@ export class SheetCanvasPopManagerService extends Disposable { skeleton, currentRender, }); + const popupInjector = this._resolveEmbeddedPopupInjector(unitId, currentRender); const id = this._globalPopupManagerService.addPopup({ ...popup, unitId, subUnitId, + connectorInjector: popupInjector, anchorRect: position, anchorRect$: position$, canvasElement: currentRender.engine.getCanvasElement(), @@ -528,9 +549,10 @@ export class SheetCanvasPopManagerService extends Disposable { // If the range changes, the popup should change with it. And if the range vanished, the popup should be removed. const watchedRange: IRange = { startRow: row, endRow: row, startColumn: col, endColumn: col }; + const trackedDisposable = this._trackPopupDisposable(disposableCollection); disposableCollection.add(this._refRangeService.watchRange(unitId, subUnitId, watchedRange, (_, after) => { if (!after) { - disposableCollection.dispose(); + trackedDisposable.dispose(); } else { updateRowCol(after.startRow, after.startColumn); } @@ -538,7 +560,7 @@ export class SheetCanvasPopManagerService extends Disposable { return { dispose() { - disposableCollection.dispose(); + trackedDisposable.dispose(); //@ts-ignore worksheet = null; //@ts-ignore @@ -595,10 +617,12 @@ export class SheetCanvasPopManagerService extends Disposable { skeleton, currentRender, }); + const popupInjector = this._resolveEmbeddedPopupInjector(unitId, currentRender); const id = this._globalPopupManagerService.addPopup({ ...popup, unitId, subUnitId, + connectorInjector: popupInjector, anchorRect: position, anchorRect$: position$, canvasElement: currentRender.engine.getCanvasElement(), @@ -615,9 +639,10 @@ export class SheetCanvasPopManagerService extends Disposable { // If the range changes, the popup should change with it. And if the range vanished, the popup should be removed. const watchedRange = { ...range }; + const trackedDisposable = this._trackPopupDisposable(disposableCollection); disposableCollection.add(this._refRangeService.watchRange(unitId, subUnitId, watchedRange, (_, after) => { if (!after) { - disposableCollection.dispose(); + trackedDisposable.dispose(); } else { updateRange(after); } @@ -625,12 +650,39 @@ export class SheetCanvasPopManagerService extends Disposable { return { dispose() { - disposableCollection.dispose(); + trackedDisposable.dispose(); }, canDispose: () => this._globalPopupManagerService.activePopupId !== id, }; } + private _resolveEmbeddedPopupInjector(unitId: string, currentRender: IRender): Injector | undefined { + return this._univerInstanceService.getUnitCreateOptions(unitId)?.embeddedRender === true + ? currentRender.getInjector?.() + : undefined; + } + + private _trackPopupDisposable(disposable: IDisposable): IDisposable { + let disposed = false; + const trackedDisposables = this._popupDisposables; + const trackedDisposable = { + dispose: () => { + if (disposed) { + return; + } + + disposed = true; + trackedDisposables.delete(trackedDisposable); + disposable.dispose(); + }, + }; + + trackedDisposables.add(trackedDisposable); + return { + dispose: () => trackedDisposable.dispose(), + }; + } + /** * * @param initialRow @@ -654,7 +706,10 @@ export class SheetCanvasPopManagerService extends Disposable { const updatePosition = () => position$.next(this._calcCellPositionByCell(row, col, currentRender, skeleton, activeViewport)); const disposable = new DisposableCollection(); - disposable.add(currentRender.engine.clientRect$.subscribe(() => updatePosition())); + disposable.add(currentRender.engine.clientRect$.subscribe({ + next: () => updatePosition(), + error: () => {}, + })); disposable.add(fromEventSubject(currentRender.engine.onTransformChange$).pipe(throttleTime(16)).subscribe(() => updatePosition())); disposable.add(this._commandService.onCommandExecuted((commandInfo) => { if (commandInfo.id === SetWorksheetRowAutoHeightMutation.id) { @@ -769,7 +824,10 @@ export class SheetCanvasPopManagerService extends Disposable { }; const disposable = new DisposableCollection(); - disposable.add(currentRender.engine.clientRect$.subscribe(() => updatePosition())); + disposable.add(currentRender.engine.clientRect$.subscribe({ + next: () => updatePosition(), + error: () => {}, + })); disposable.add(this._commandService.onCommandExecuted((commandInfo) => { if (commandInfo.id === SetWorksheetRowAutoHeightMutation.id) { diff --git a/packages/sheets-ui/src/services/cell-popup-manager.service.ts b/packages/sheets-ui/src/services/cell-popup-manager.service.ts index 0daaa255a60e..39f0d5c5cbbe 100644 --- a/packages/sheets-ui/src/services/cell-popup-manager.service.ts +++ b/packages/sheets-ui/src/services/cell-popup-manager.service.ts @@ -182,4 +182,29 @@ export class CellPopupManagerService extends Disposable { subUnitMap.realDeleteValue(row, col); } } + + hidePopupsForUnit(unitId: string, subUnitId?: string): void { + const unitMap = this._cellPopupMap.get(unitId); + if (!unitMap) { + return; + } + + const targets: Array<{ subUnitId: string; row: number; col: number }> = []; + const subUnitEntries: Array<[string, ObjectMatrix | undefined]> = subUnitId + ? [[subUnitId, unitMap.get(subUnitId)]] + : [...unitMap.entries()]; + + subUnitEntries.forEach(([currentSubUnitId, subUnitMap]) => { + subUnitMap?.forValue((row, col) => { + targets.push({ subUnitId: currentSubUnitId, row, col }); + }); + }); + + targets.forEach((target) => this.hidePopup(unitId, target.subUnitId, target.row, target.col)); + if (subUnitId) { + unitMap.delete(subUnitId); + } else { + this._cellPopupMap.delete(unitId); + } + } } diff --git a/packages/sheets-ui/src/services/editor-bridge.service.ts b/packages/sheets-ui/src/services/editor-bridge.service.ts index dd210dc5a34b..2b517c802fdf 100644 --- a/packages/sheets-ui/src/services/editor-bridge.service.ts +++ b/packages/sheets-ui/src/services/editor-bridge.service.ts @@ -44,6 +44,7 @@ export interface IEditorBridgeServiceVisibleParam { eventType: DeviceInputEventType; unitId: string; keycode?: KeyCode; + initialValue?: string; } export interface ICurrentEditCellParam { @@ -185,7 +186,7 @@ export class EditorBridgeService extends Disposable implements IEditorBridgeServ if (!this._currentEditCell || !this._currentEditCellState) return; const { unitId, sheetId, primary, scene, engine } = this._currentEditCell; - const workbook = this._univerInstanceService.getCurrentUnitOfType(UniverInstanceType.UNIVER_SHEET); + const workbook = this._getWorkbookForEditUnit(unitId); if (!workbook || workbook.getUnitId() !== unitId) return; const worksheet = workbook.getActiveSheet(); @@ -302,7 +303,7 @@ export class EditorBridgeService extends Disposable implements IEditorBridgeServ if (!this._currentEditCell) return; const { unitId, sheetId, primary, scene, engine } = this._currentEditCell; - const workbook = this._univerInstanceService.getCurrentUnitOfType(UniverInstanceType.UNIVER_SHEET); + const workbook = this._getWorkbookForEditUnit(unitId); if (!workbook || workbook.getUnitId() !== unitId) return; const worksheet = workbook.getActiveSheet(); @@ -410,6 +411,11 @@ export class EditorBridgeService extends Disposable implements IEditorBridgeServ }; } + private _getWorkbookForEditUnit(unitId: string): Nullable { + return this._univerInstanceService.getUnit(unitId, UniverInstanceType.UNIVER_SHEET) ?? + this._univerInstanceService.getCurrentUnitOfType(UniverInstanceType.UNIVER_SHEET); + } + getCurrentEditorId() { return this._editorUnitId; } diff --git a/packages/sheets-ui/src/services/editor/__tests__/cell-editor-resize.service.spec.ts b/packages/sheets-ui/src/services/editor/__tests__/cell-editor-resize.service.spec.ts index e726ab0396b5..0e328d35860d 100644 --- a/packages/sheets-ui/src/services/editor/__tests__/cell-editor-resize.service.spec.ts +++ b/packages/sheets-ui/src/services/editor/__tests__/cell-editor-resize.service.spec.ts @@ -42,6 +42,7 @@ import { SheetInterceptorService, SheetSkeletonService } from '@univerjs/sheets' import { DesktopLayoutService, ILayoutService } from '@univerjs/ui'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { EditorBridgeService, IEditorBridgeService } from '../../editor-bridge.service'; +import { EmbedFloatingGeometryService } from '../../sheet-embed-integration.service'; import { SheetSkeletonManagerService } from '../../sheet-skeleton-manager.service'; import { CellEditorManagerService, ICellEditorManagerService } from '../cell-editor-manager.service'; import { SheetCellEditorResizeService } from '../cell-editor-resize.service'; @@ -145,6 +146,14 @@ class TestUniverInstanceService { } } +class TestUniverInstanceServiceWithDifferentCurrent extends TestUniverInstanceService { + override getCurrentUnitOfType(type: UniverInstanceType) { + return type === UniverInstanceType.UNIVER_SHEET + ? { getUnitId: () => 'host-or-other-sheet' } + : null; + } +} + class TestRenderManagerService { readonly sheetCanvasElement = { style: { width: '800px' }, @@ -371,6 +380,84 @@ describe('SheetCellEditorResizeService', () => { })); }); + it('positions embedded sheet editors against the child content root', async () => { + vi.stubGlobal('window', new EventTarget()); + vi.stubGlobal('document', { activeElement: { dataset: {} } }); + const injector = new Injector(); + injector.add([ILogService, { useClass: DesktopLogService }]); + injector.add([IContextService, { useClass: ContextService }]); + injector.add([IUniverInstanceService, { useClass: TestUniverInstanceService as never }]); + injector.add([ICommandService, { useClass: CommandService }]); + injector.add([IUndoRedoService, { useClass: LocalUndoRedoService }]); + injector.add([ThemeService]); + injector.add([ILayoutService, { useClass: TestLayoutService as never }]); + injector.add([DocSelectionManagerService]); + injector.add([SheetInterceptorService]); + injector.add([SheetSkeletonService]); + injector.add([IEditorService, { useClass: EditorService }]); + injector.add([ICellEditorManagerService, { useClass: TestCellEditorManagerService as never }]); + injector.add([IEditorBridgeService, { useClass: TestEditorBridgeService as never }]); + injector.add([IRenderManagerService, { useClass: TestRenderManagerService as never }]); + injector.add([IConfigService, { useClass: ConfigService }]); + injector.add([EmbedFloatingGeometryService]); + injector.add([SheetCellEditorResizeService]); + const geometryService = injector.get(EmbedFloatingGeometryService); + geometryService.register({ + embedId: 'embed-1', + childUnitId: 'unit-1', + root: {} as HTMLElement, + contentRoot: { + getBoundingClientRect: () => ({ left: 30, top: 50 }), + } as HTMLElement, + }); + + injector.get(SheetCellEditorResizeService).fitTextSize(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + const editorManager = injector.get(ICellEditorManagerService) as unknown as TestCellEditorManagerService; + expect(editorManager.getState()).toEqual(expect.objectContaining({ + startX: 120, + startY: 90, + endX: 250, + endY: 134, + show: true, + })); + }); + + it('uses the editing unit renderer even when the current sheet unit is different', async () => { + vi.stubGlobal('window', new EventTarget()); + vi.stubGlobal('document', { activeElement: { dataset: {} } }); + const injector = new Injector(); + injector.add([ILogService, { useClass: DesktopLogService }]); + injector.add([IContextService, { useClass: ContextService }]); + injector.add([IUniverInstanceService, { useClass: TestUniverInstanceServiceWithDifferentCurrent as never }]); + injector.add([ICommandService, { useClass: CommandService }]); + injector.add([IUndoRedoService, { useClass: LocalUndoRedoService }]); + injector.add([ThemeService]); + injector.add([ILayoutService, { useClass: TestLayoutService as never }]); + injector.add([DocSelectionManagerService]); + injector.add([SheetInterceptorService]); + injector.add([SheetSkeletonService]); + injector.add([IEditorService, { useClass: EditorService }]); + injector.add([ICellEditorManagerService, { useClass: TestCellEditorManagerService as never }]); + injector.add([IEditorBridgeService, { useClass: TestEditorBridgeService as never }]); + injector.add([IRenderManagerService, { useClass: TestRenderManagerService as never }]); + injector.add([IConfigService, { useClass: ConfigService }]); + injector.add([SheetCellEditorResizeService]); + + injector.get(SheetCellEditorResizeService).fitTextSize(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + const editorManager = injector.get(ICellEditorManagerService) as unknown as TestCellEditorManagerService; + expect(editorManager.getState()).toEqual(expect.objectContaining({ + startX: 120, + startY: 90, + endX: 250, + endY: 134, + show: true, + })); + }); + it('refits the editor when its container size drifts from the edited cell', async () => { vi.stubGlobal('window', new EventTarget()); vi.stubGlobal('document', { activeElement: { dataset: {} } }); diff --git a/packages/sheets-ui/src/services/editor/cell-editor-resize.service.ts b/packages/sheets-ui/src/services/editor/cell-editor-resize.service.ts index d3ef1b717d31..6471683fd1be 100644 --- a/packages/sheets-ui/src/services/editor/cell-editor-resize.service.ts +++ b/packages/sheets-ui/src/services/editor/cell-editor-resize.service.ts @@ -16,13 +16,14 @@ import type { DocumentDataModel, IPosition, Nullable } from '@univerjs/core'; import type { DocumentSkeleton, IDocumentLayoutObject, Scene } from '@univerjs/engine-render'; -import { Disposable, DOCS_NORMAL_EDITOR_UNIT_ID_KEY, HorizontalAlign, IConfigService, IUniverInstanceService, UniverInstanceType, VerticalAlign, WrapStrategy } from '@univerjs/core'; +import { Disposable, DOCS_NORMAL_EDITOR_UNIT_ID_KEY, HorizontalAlign, IConfigService, IUniverInstanceService, Optional, UniverInstanceType, VerticalAlign, WrapStrategy } from '@univerjs/core'; import { DocSkeletonManagerService } from '@univerjs/docs'; import { DOCS_COMPONENT_MAIN_LAYER_INDEX, VIEWPORT_KEY } from '@univerjs/docs-ui'; import { convertTextRotation, fixLineWidthByScale, getCurrentTypeOfRenderer, IRenderManagerService, Rect, ScrollBar } from '@univerjs/engine-render'; import { ILayoutService } from '@univerjs/ui'; import { getEditorObject } from '../../basics/editor/get-editor-object'; import { IEditorBridgeService } from '../editor-bridge.service'; +import { ISheetEmbedFloatingGeometryService } from '../sheet-embed-integration.service'; import { SheetSkeletonManagerService } from '../sheet-skeleton-manager.service'; import { ICellEditorManagerService } from './cell-editor-manager.service'; @@ -42,7 +43,8 @@ export class SheetCellEditorResizeService extends Disposable { @IEditorBridgeService private readonly _editorBridgeService: IEditorBridgeService, @IRenderManagerService private readonly _renderManagerService: IRenderManagerService, @IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService, - @IConfigService private readonly _configService: IConfigService + @IConfigService private readonly _configService: IConfigService, + @Optional(ISheetEmbedFloatingGeometryService) private readonly _embedFloatingGeometryService?: ISheetEmbedFloatingGeometryService ) { super(); } @@ -60,8 +62,7 @@ export class SheetCellEditorResizeService extends Disposable { } private get _renderer() { - const currentUnitId = this._univerInstanceService.getCurrentUnitOfType(UniverInstanceType.UNIVER_SHEET)?.getUnitId(); - return this._editingUnitId === currentUnitId ? this._editingRenderer : this._currentRenderer; + return this._editingRenderer ?? this._currentRenderer; } private get _sheetSkeletonManagerService() { @@ -347,7 +348,7 @@ export class SheetCellEditorResizeService extends Disposable { callback?.(); }, 0); - const contentBoundingRect = this._layoutService.getContentElement().getBoundingClientRect(); + const contentBoundingRect = this._getEditorContentElement().getBoundingClientRect(); const canvasBoundingRect = canvasElement.getBoundingClientRect(); startX = startX * scaleAdjust + (canvasBoundingRect.left - contentBoundingRect.left); startY = startY * scaleAdjust + (canvasBoundingRect.top - contentBoundingRect.top); @@ -369,6 +370,12 @@ export class SheetCellEditorResizeService extends Disposable { }); } + private _getEditorContentElement(): HTMLElement { + return this._embedFloatingGeometryService + ?.getRegistrationByChildUnitId(this._editingUnitId) + ?.contentRoot ?? this._layoutService.getContentElement(); + } + /** * Since the document does not support cell background color, an additional rect needs to be added. */ diff --git a/packages/sheets-ui/src/services/selection/__tests__/selection-render.service.spec.ts b/packages/sheets-ui/src/services/selection/__tests__/selection-render.service.spec.ts index 30c0bc8e30f3..c329684c8814 100644 --- a/packages/sheets-ui/src/services/selection/__tests__/selection-render.service.spec.ts +++ b/packages/sheets-ui/src/services/selection/__tests__/selection-render.service.spec.ts @@ -82,7 +82,7 @@ describe('SheetSelectionRenderService', () => { return { ...testBed, service }; } - it('renders selections from model changes and respects SELECTIONS_ENABLED', () => { + it('renders selections from model changes and respects SELECTIONS_ENABLED', async () => { const testBed = createRenderTestBed({ dependencies: [ [IShortcutService, { useClass: TestShortcutService }], @@ -102,6 +102,7 @@ describe('SheetSelectionRenderService', () => { sheetId: 'sheet1', skeleton: skeleton as any, }); + await Promise.resolve(); // The skeleton change listener ensures there is at least one selection. expect(renderService.getSelectionControls().length).toBeGreaterThan(0); diff --git a/packages/sheets-ui/src/services/selection/base-selection-render.service.ts b/packages/sheets-ui/src/services/selection/base-selection-render.service.ts index fc33c14586b8..dd1d42e2bb45 100644 --- a/packages/sheets-ui/src/services/selection/base-selection-render.service.ts +++ b/packages/sheets-ui/src/services/selection/base-selection-render.service.ts @@ -201,6 +201,24 @@ export class BaseSelectionRenderService extends Disposable implements ISheetSele this._initMoving(); } + override dispose(): void { + if (this._disposed) { + return; + } + + this._clearUpdatingListeners(); + this._reset(); + this._escapeShortcutDisposable?.dispose(); + this._escapeShortcutDisposable = null; + + this._controlFillConfig$.complete(); + this._selectionMoveEnd$.complete(); + this._selectionMoving$.complete(); + this._selectionMoveStart$.complete(); + + super.dispose(); + } + /** * If true, the selector will respond to the range of merged cells and automatically extend the selected range. If false, it will ignore the merged cells. */ diff --git a/packages/sheets-ui/src/services/selection/selection-shape-extension.ts b/packages/sheets-ui/src/services/selection/selection-shape-extension.ts index fca785e0f2dd..f81848787a0a 100644 --- a/packages/sheets-ui/src/services/selection/selection-shape-extension.ts +++ b/packages/sheets-ui/src/services/selection/selection-shape-extension.ts @@ -19,7 +19,7 @@ import type { IMouseEvent, IPointerEvent, Scene, SpreadsheetSkeleton, Viewport } import type { ISelectionWithStyle } from '@univerjs/sheets'; import type { Subscription } from 'rxjs'; import type { SelectionControl } from './selection-control'; -import { ColorKit, IUniverInstanceService, Quantity, UniverInstanceType } from '@univerjs/core'; +import { ColorKit, IUniverInstanceService, LookUp, Quantity, UniverInstanceType } from '@univerjs/core'; import { CURSOR_TYPE, IRenderManagerService, Rect, ScrollTimer, ScrollTimerType, SHEET_VIEWPORT_KEY, Vector2, withCurrentTypeOfRenderer } from '@univerjs/engine-render'; import { attachSelectionWithCoord, SELECTION_CONTROL_BORDER_BUFFER_WIDTH } from '@univerjs/sheets'; import { SheetSkeletonManagerService } from '../sheet-skeleton-manager.service'; @@ -186,7 +186,7 @@ export class SelectionShapeExtension { [leftControl, rightControl, topControl, bottomControl].forEach((control) => { control.onPointerEnter$.subscribeEvent(() => { - const permissionCheck = this._injector.get(ISheetSelectionRenderService, Quantity.OPTIONAL) + const permissionCheck = this._injector.get(ISheetSelectionRenderService, Quantity.OPTIONAL, LookUp.SELF) ?.interceptor .fetchThroughInterceptors(RANGE_MOVE_PERMISSION_CHECK)(false, null); if (permissionCheck === false) { @@ -358,7 +358,7 @@ export class SelectionShapeExtension { } const { offsetX: moveOffsetX, offsetY: moveOffsetY } = moveEvt; - const permissionCheck = this._injector.get(ISheetSelectionRenderService, Quantity.OPTIONAL) + const permissionCheck = this._injector.get(ISheetSelectionRenderService, Quantity.OPTIONAL, LookUp.SELF) ?.interceptor .fetchThroughInterceptors(RANGE_MOVE_PERMISSION_CHECK)(false, null); if (permissionCheck === false) { @@ -599,7 +599,7 @@ export class SelectionShapeExtension { const { fillControl } = this._control; fillControl.onPointerEnter$.subscribeEvent((evt: IPointerEvent | IMouseEvent) => { - const permissionCheck = this._injector.get(ISheetSelectionRenderService).interceptor.fetchThroughInterceptors(RANGE_FILL_PERMISSION_CHECK)(false, { x: evt.offsetX, y: evt.offsetY, skeleton: this._skeleton, scene: this._scene }); + const permissionCheck = this._injector.get(ISheetSelectionRenderService, LookUp.SELF).interceptor.fetchThroughInterceptors(RANGE_FILL_PERMISSION_CHECK)(false, { x: evt.offsetX, y: evt.offsetY, skeleton: this._skeleton, scene: this._scene }); if (!permissionCheck) { return; @@ -841,7 +841,7 @@ export class SelectionShapeExtension { const { offsetX: moveOffsetX, offsetY: moveOffsetY } = moveEvt; const currentViewport = scene.getActiveViewportByCoord(Vector2.FromArray([moveOffsetX, moveOffsetY])); - const permissionCheck = this._injector.get(ISheetSelectionRenderService).interceptor.fetchThroughInterceptors(RANGE_FILL_PERMISSION_CHECK)(false, { x: evt.offsetX, y: evt.offsetY, skeleton: this._skeleton, scene: this._scene }); + const permissionCheck = this._injector.get(ISheetSelectionRenderService, LookUp.SELF).interceptor.fetchThroughInterceptors(RANGE_FILL_PERMISSION_CHECK)(false, { x: evt.offsetX, y: evt.offsetY, skeleton: this._skeleton, scene: this._scene }); if (!permissionCheck) { return; diff --git a/packages/sheets-ui/src/services/sheet-embed-integration.service.ts b/packages/sheets-ui/src/services/sheet-embed-integration.service.ts new file mode 100644 index 000000000000..e8b0738a5653 --- /dev/null +++ b/packages/sheets-ui/src/services/sheet-embed-integration.service.ts @@ -0,0 +1,303 @@ +/** + * 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 type { IDisposable, UniverInstanceType } from '@univerjs/core'; +import type { Observable } from 'rxjs'; +import { createIdentifier, toDisposable } from '@univerjs/core'; +import { Subject } from 'rxjs'; + +export const SHEET_EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE = 'data-embed-interaction-boundary-owner'; +export const SHEET_EMBED_RUNTIME_FOCUS_ROLE_ATTRIBUTE = 'data-embed-runtime-focus-role'; +export const EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE = SHEET_EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE; +export const EMBED_RUNTIME_FOCUS_ROLE_ATTRIBUTE = SHEET_EMBED_RUNTIME_FOCUS_ROLE_ATTRIBUTE; +export const SHEET_EMBED_FLOAT_DOM_ATTRIBUTE = 'data-embed-float-dom'; +export const SHEET_EMBED_ID_ATTRIBUTE = 'data-embed-id'; +export const SHEET_EMBED_HOST_UNIT_ID_ATTRIBUTE = 'data-embed-host-unit-id'; +export const SHEET_EMBED_CHILD_UNIT_ID_ATTRIBUTE = 'data-embed-child-unit-id'; +export const SHEET_EMBED_CHILD_TYPE_ATTRIBUTE = 'data-embed-child-type'; + +export interface ISheetEmbedRuntimeDomScope { + embedId: string; + hostUnitId?: string; + childUnitId?: string; + childType?: UniverInstanceType; +} + +export interface ISheetEmbedInteractionBoundaryService { + registerOwnedElement(embedId: string, element: HTMLElement): IDisposable; +} + +export const ISheetEmbedInteractionBoundaryService = createIdentifier('sheets-ui.embed-interaction-boundary.service'); + +export interface ISheetEmbedRuntimeFocusCoordinator { + readonly runtimeSessionChanged$: Observable; + acquireLease(options: { + embedId: string; + role: string; + owner?: string; + hostUnitId?: string; + childUnitId?: string; + childType?: UniverInstanceType; + associatedChildUnitIds?: string[]; + }): IDisposable; + registerElement(options: { + embedId: string; + role: string; + element: HTMLElement; + }): IDisposable; + resolveRuntimeScopeByChildUnitId(childUnitId: string | undefined): ISheetEmbedRuntimeDomScope | undefined; + resolveActiveChildSessionRuntimeScope(): ISheetEmbedRuntimeDomScope | undefined; + isChildUnitInActiveSession(unitId: string | undefined): boolean; + isChildUnitRuntimeEvent(unitId: string | undefined, target: EventTarget | null | undefined, event?: Event): boolean; +} + +export const ISheetEmbedRuntimeFocusCoordinator = createIdentifier('sheets-ui.embed-runtime-focus-coordinator'); + +export interface ISheetEmbedFloatingGeometryService { + readonly geometryInvalidated$: Observable; + getRegistrationByChildUnitId(childUnitId: string): { + root: HTMLElement; + viewport?: HTMLElement | null; + contentRoot?: HTMLElement | null; + } | undefined; +} + +export const ISheetEmbedFloatingGeometryService = createIdentifier('sheets-ui.embed-floating-geometry.service'); + +export class EmbedInteractionBoundaryService implements ISheetEmbedInteractionBoundaryService { + private readonly _roots = new Map>(); + + registerOwnedElement(embedId: string, element: HTMLElement): IDisposable { + this._mark(embedId, element); + element.querySelectorAll('*').forEach((child) => this._mark(embedId, child)); + let roots = this._roots.get(embedId); + if (!roots) { + roots = new Set(); + this._roots.set(embedId, roots); + } + roots.add(element); + + return toDisposable(() => { + roots?.delete(element); + element.removeAttribute(SHEET_EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE); + element.removeAttribute(SHEET_EMBED_RUNTIME_FOCUS_ROLE_ATTRIBUTE); + element.querySelectorAll('*').forEach((child) => { + child.removeAttribute(SHEET_EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE); + child.removeAttribute(SHEET_EMBED_RUNTIME_FOCUS_ROLE_ATTRIBUTE); + }); + }); + } + + contains(embedId: string | undefined, target: EventTarget | null | undefined): boolean { + if (!(target instanceof HTMLElement)) { + return false; + } + + if (!embedId) { + return [...this._roots.values()].some((roots) => [...roots].some((root) => root.contains(target))); + } + + return this._roots.get(embedId) != null && [...this._roots.get(embedId)!].some((root) => root.contains(target)); + } + + private _mark(embedId: string, element: HTMLElement): void { + element.setAttribute(SHEET_EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE, embedId); + element.setAttribute(SHEET_EMBED_RUNTIME_FOCUS_ROLE_ATTRIBUTE, 'child-editor'); + } +} + +export class EmbedRuntimeFocusCoordinator implements ISheetEmbedRuntimeFocusCoordinator { + private readonly _leases = new Set<{ + embedId: string; + role: string; + owner?: string; + childUnitId?: string; + hostUnitId?: string; + childType?: UniverInstanceType; + associatedChildUnitIds?: string[]; + }>(); + + private readonly _elements = new Map>(); + readonly runtimeSessionChanged$ = new Subject(); + + acquireLease(options: { + embedId: string; + role: string; + owner?: string; + childUnitId?: string; + hostUnitId?: string; + childType?: UniverInstanceType; + associatedChildUnitIds?: string[]; + }): IDisposable { + this._leases.add(options); + if (options.role === 'child-session') { + this.runtimeSessionChanged$.next(); + } + + return toDisposable(() => { + this._leases.delete(options); + if (options.role === 'child-session') { + this.runtimeSessionChanged$.next(); + } + }); + } + + registerElement(options: { embedId: string; role: string; element: HTMLElement }): IDisposable { + options.element.setAttribute(SHEET_EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE, options.embedId); + options.element.setAttribute(SHEET_EMBED_RUNTIME_FOCUS_ROLE_ATTRIBUTE, options.role); + let elements = this._elements.get(options.embedId); + if (!elements) { + elements = new Set(); + this._elements.set(options.embedId, elements); + } + elements.add(options.element); + + return toDisposable(() => { + elements?.delete(options.element); + options.element.removeAttribute(SHEET_EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE); + options.element.removeAttribute(SHEET_EMBED_RUNTIME_FOCUS_ROLE_ATTRIBUTE); + }); + } + + containsElement(embedId: string, element: HTMLElement | null | undefined): boolean { + return !!element && this._elements.get(embedId)?.has(element) === true; + } + + registerRuntimeScope(options: { embedId: string; hostUnitId?: string; childUnitId?: string; childType?: UniverInstanceType }): IDisposable { + const lease = { ...options, role: 'runtime' }; + this._leases.add(lease); + this.runtimeSessionChanged$.next(); + + return toDisposable(() => { + this._leases.delete(lease); + this.runtimeSessionChanged$.next(); + }); + } + + hasChildInteractionLease(embedId: string | undefined): boolean { + return [...this._leases].some((lease) => lease.embedId === embedId && lease.role !== 'runtime'); + } + + hasHostPreservingChildFocusLeaseForHost(hostUnitId: string | undefined): boolean { + return [...this._leases].some((lease) => lease.role !== 'runtime' && (!hostUnitId || lease.hostUnitId === hostUnitId)); + } + + resolveRuntimeScopeByChildUnitId(childUnitId: string | undefined): ISheetEmbedRuntimeDomScope | undefined { + const lease = [...this._leases].find((item) => matchesChildUnitId(item, childUnitId)); + return lease ? { embedId: lease.embedId, hostUnitId: lease.hostUnitId, childUnitId: lease.childUnitId, childType: lease.childType } : undefined; + } + + resolveActiveChildSessionRuntimeScope(): ISheetEmbedRuntimeDomScope | undefined { + const lease = [...this._leases].find((item) => item.role === 'child-session'); + return lease ? { embedId: lease.embedId, hostUnitId: lease.hostUnitId, childUnitId: lease.childUnitId, childType: lease.childType } : undefined; + } + + isChildUnitInActiveSession(unitId: string | undefined): boolean { + return [...this._leases].some((lease) => lease.role !== 'runtime' && (!lease.childUnitId || matchesChildUnitId(lease, unitId))); + } + + isChildUnitRuntimeEvent(unitId: string | undefined, target: EventTarget | null | undefined): boolean { + return this.isChildUnitInActiveSession(unitId) || ( + target instanceof HTMLElement && + target.closest(`[${SHEET_EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE}]`) != null + ); + } +} + +function matchesChildUnitId(lease: { childUnitId?: string; associatedChildUnitIds?: string[] }, unitId: string | undefined): boolean { + return unitId != null && (lease.childUnitId === unitId || lease.associatedChildUnitIds?.includes(unitId) === true); +} + +export class EmbedFloatingGeometryService implements ISheetEmbedFloatingGeometryService { + private readonly _registrations = new Map(); + private readonly _geometryInvalidated$ = new Subject(); + readonly geometryInvalidated$ = this._geometryInvalidated$.asObservable(); + + register(registration: { embedId: string; childUnitId?: string; root: HTMLElement; viewport?: HTMLElement | null; contentRoot?: HTMLElement | null }): IDisposable { + this._registrations.set(registration.embedId, registration); + this._geometryInvalidated$.next({}); + + return toDisposable(() => { + this._registrations.delete(registration.embedId); + this._geometryInvalidated$.next({}); + }); + } + + getRegistrationByChildUnitId(childUnitId: string) { + return [...this._registrations.values()].find((registration) => registration.childUnitId === childUnitId); + } +} + +export function resolveSheetEmbedRuntimeDomScope(element: HTMLElement | null | undefined): ISheetEmbedRuntimeDomScope | undefined { + if (!element) { + return undefined; + } + + const embedId = element.closest(`[${SHEET_EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE}]`) + ?.getAttribute(SHEET_EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE) ?? undefined; + const container = resolveSheetEmbedFloatDomContainer(element, embedId); + const resolvedEmbedId = container?.getAttribute(SHEET_EMBED_ID_ATTRIBUTE) ?? embedId; + if (!resolvedEmbedId) { + return undefined; + } + + return { + embedId: resolvedEmbedId, + hostUnitId: container?.getAttribute(SHEET_EMBED_HOST_UNIT_ID_ATTRIBUTE) ?? undefined, + childUnitId: container?.getAttribute(SHEET_EMBED_CHILD_UNIT_ID_ATTRIBUTE) ?? undefined, + childType: readChildType(container), + }; +} + +export function resolveActiveSheetEmbedRuntimeDomScope(ownerDocument: Document = document): ISheetEmbedRuntimeDomScope | undefined { + const activeContainer = ownerDocument.querySelector( + `[${SHEET_EMBED_FLOAT_DOM_ATTRIBUTE}="true"][data-embed-float-stage="stage2"]` + ); + if (!activeContainer) { + return undefined; + } + + return resolveSheetEmbedRuntimeDomScope(activeContainer); +} + +function resolveSheetEmbedFloatDomContainer(element: HTMLElement | null | undefined, embedId?: string): HTMLElement | undefined { + const ownContainer = element?.closest(`[${SHEET_EMBED_FLOAT_DOM_ATTRIBUTE}="true"]`); + if (ownContainer && (!embedId || ownContainer.getAttribute(SHEET_EMBED_ID_ATTRIBUTE) === embedId)) { + return ownContainer; + } + + const ownerDocument = element?.ownerDocument ?? (typeof document === 'undefined' ? undefined : document); + if (!ownerDocument || !embedId) { + return ownContainer ?? undefined; + } + + return ownerDocument.querySelector( + `[${SHEET_EMBED_FLOAT_DOM_ATTRIBUTE}="true"][${SHEET_EMBED_ID_ATTRIBUTE}="${escapeAttributeValue(embedId)}"]` + ) ?? undefined; +} + +function escapeAttributeValue(value: string): string { + return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); +} + +function readChildType(container: HTMLElement | undefined): UniverInstanceType | undefined { + const value = container?.getAttribute(SHEET_EMBED_CHILD_TYPE_ATTRIBUTE); + if (value == null || value === '') { + return undefined; + } + + return Number(value) as UniverInstanceType; +} diff --git a/packages/docs-ui/src/__tests__/index.spec.ts b/packages/sheets-ui/src/services/sheet-embed-runtime.service.ts similarity index 53% rename from packages/docs-ui/src/__tests__/index.spec.ts rename to packages/sheets-ui/src/services/sheet-embed-runtime.service.ts index c112f8d218ee..84a22912b3c3 100644 --- a/packages/docs-ui/src/__tests__/index.spec.ts +++ b/packages/sheets-ui/src/services/sheet-embed-runtime.service.ts @@ -14,17 +14,18 @@ * limitations under the License. */ -import { describe, expect, it } from 'vitest'; -import { - DOC_TABLE_BLOCK_MENU_ID, - INSERT_BELLOW_MENU_ID, - ParagraphMenuInsertBelowSubmenuItemFactory, -} from '../index'; +import type { IDisposable } from '@univerjs/core'; +import { createIdentifier } from '@univerjs/core'; -describe('docs-ui public exports', () => { - it('re-exports table block paragraph menu ids and factories', () => { - expect(DOC_TABLE_BLOCK_MENU_ID).toBe('doc.menu.table-block'); - expect(INSERT_BELLOW_MENU_ID).toBe('doc.menu.insert-bellow'); - expect(ParagraphMenuInsertBelowSubmenuItemFactory).toBeTypeOf('function'); - }); -}); +export interface ISheetEmbedTabMountParams { + hostUnitId: string; + hostAnchorId: string; + embedId: string; +} + +export interface ISheetEmbedRuntimeService { + mountSheetTab(params: ISheetEmbedTabMountParams): IDisposable | undefined; + clearTab(embedId?: string): void; +} + +export const ISheetEmbedRuntimeService = createIdentifier('sheet-ui.embed-runtime.service'); diff --git a/packages/sheets-ui/src/services/sheet-host-chrome-override.service.ts b/packages/sheets-ui/src/services/sheet-host-chrome-override.service.ts new file mode 100644 index 000000000000..59f66c3d1fcf --- /dev/null +++ b/packages/sheets-ui/src/services/sheet-host-chrome-override.service.ts @@ -0,0 +1,30 @@ +/** + * 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 type { Observable } from 'rxjs'; +import { createIdentifier } from '@univerjs/core'; + +export interface ISheetHostChromeOverride { + entry?: string; + hostUnitId?: string; +} + +export interface ISheetHostChromeOverrideService { + readonly override$: Observable; + getOverride?(): ISheetHostChromeOverride | null; +} + +export const ISheetHostChromeOverrideService = createIdentifier('sheet-ui.host-chrome-override.service'); diff --git a/packages/sheets-ui/src/services/sheet-skeleton-manager.service.ts b/packages/sheets-ui/src/services/sheet-skeleton-manager.service.ts index 1ae819d48f20..cf3556c49ec7 100644 --- a/packages/sheets-ui/src/services/sheet-skeleton-manager.service.ts +++ b/packages/sheets-ui/src/services/sheet-skeleton-manager.service.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import type { Nullable, Workbook } from '@univerjs/core'; +import type { DependencyIdentifier, Nullable, Workbook } from '@univerjs/core'; import type { IRender, IRenderContext, IRenderModule, SpreadsheetSkeleton } from '@univerjs/engine-render'; import type { ISheetSkeletonManagerParam } from '@univerjs/sheets'; import { Disposable, Inject } from '@univerjs/core'; @@ -189,10 +189,7 @@ export class SheetSkeletonManagerService extends Disposable implements IRenderMo render.scene.getViewport(SHEET_VIEWPORT_KEY.VIEW_LEFT_TOP)!.setViewportSize({ height: size, }); - const selectionService = render?.with(SheetsSelectionsService); - const selectionRenderService = render?.with(ISheetSelectionRenderService); - const currSelections = selectionService.getCurrentSelections(); - selectionRenderService.resetSelectionsByModelData(currSelections); + this._resetSelectionsIfAvailable(render); const sheetSkeletonManagerParam = this._sheetSkeletonService.getSkeletonParam(render.unitId, sheetId); if (sheetSkeletonManagerParam) { @@ -229,10 +226,7 @@ export class SheetSkeletonManagerService extends Disposable implements IRenderMo render.scene.getViewport(SHEET_VIEWPORT_KEY.VIEW_LEFT_TOP)!.setViewportSize({ width: size, }); - const selectionService = render?.with(SheetsSelectionsService); - const selectionRenderService = render?.with(ISheetSelectionRenderService); - const currSelections = selectionService.getCurrentSelections(); - selectionRenderService.resetSelectionsByModelData(currSelections); + this._resetSelectionsIfAvailable(render); const sheetSkeletonManagerParam = this._sheetSkeletonService.getSkeletonParam(render.unitId, sheetId); if (sheetSkeletonManagerParam) { @@ -240,4 +234,27 @@ export class SheetSkeletonManagerService extends Disposable implements IRenderMo this._currentSkeleton$.next(sheetSkeletonManagerParam); } } + + private _resetSelectionsIfAvailable(render: IRender): void { + const selectionService = this._tryGetRenderDependency(render, SheetsSelectionsService); + const selectionRenderService = this._tryGetRenderDependency(render, ISheetSelectionRenderService); + if (!selectionService || !selectionRenderService) { + return; + } + + const currSelections = selectionService.getCurrentSelections(); + selectionRenderService.resetSelectionsByModelData(currSelections); + } + + private _tryGetRenderDependency(render: IRender, dependency: DependencyIdentifier): Nullable { + try { + return render.with(dependency); + } catch (error) { + if (error instanceof Error && (error.message.includes('DependencyNotFoundError') || error.message.includes('Cannot find'))) { + return null; + } + + throw error; + } + } } diff --git a/packages/sheets-ui/src/services/sheets-render.service.ts b/packages/sheets-ui/src/services/sheets-render.service.ts index 03fb2402ba61..b1d9ae8b5dc2 100644 --- a/packages/sheets-ui/src/services/sheets-render.service.ts +++ b/packages/sheets-ui/src/services/sheets-render.service.ts @@ -79,22 +79,23 @@ export class SheetsRenderService extends RxDisposable { .pipe(takeUntil(this.dispose$)) .subscribe((event) => this._createRenderer(event.unit, event.options)); this._instanceSrv.getAllUnitsForType(UniverInstanceType.UNIVER_SHEET) - .forEach((workbook) => this._createRenderer(workbook)); + .forEach((workbook) => this._createRenderer(workbook, this._instanceSrv.getUnitCreateOptions(workbook.getUnitId()) ?? undefined)); this._instanceSrv.getTypeOfUnitDisposed$(UniverInstanceType.UNIVER_SHEET) .pipe(takeUntil(this.dispose$)) .subscribe((workbook) => this._disposeRenderer(workbook)); } private _createRenderer(workbook: Workbook, createUnitOptions?: ICreateUnitOptions): void { - const unitId = workbook.getUnitId(); - this._renderManagerService.created$.subscribe((renderer) => { - if (renderer.unitId === unitId) { - renderer.engine.getCanvas().setId(`${SHEET_MAIN_CANVAS_ID}_${unitId}`); - renderer.engine.getCanvas().getContext().setId(`${SHEET_MAIN_CANVAS_ID}_${unitId}`); - } - }); + if (createUnitOptions?.skipAutoRender) { + return; + } - this._renderManagerService.createRender(unitId, createUnitOptions); + const unitId = workbook.getUnitId(); + const renderer = this._renderManagerService.createRender(unitId, createUnitOptions); + const canvas = renderer.engine.getCanvas(); + canvas.setId(`${SHEET_MAIN_CANVAS_ID}_${unitId}`); + canvas.getCanvasEle().dataset.uUnitId = unitId; + canvas.getContext().setId(`${SHEET_MAIN_CANVAS_ID}_${unitId}`); } private _disposeRenderer(workbook: Workbook): void { diff --git a/packages/sheets-ui/src/views/border-panel/BorderPanel.tsx b/packages/sheets-ui/src/views/border-panel/BorderPanel.tsx index f1c7bea1ea43..a710c448aa95 100644 --- a/packages/sheets-ui/src/views/border-panel/BorderPanel.tsx +++ b/packages/sheets-ui/src/views/border-panel/BorderPanel.tsx @@ -24,58 +24,7 @@ import { BorderStyleManagerService, SheetsSelectionsService } from '@univerjs/sh import { IconManager, useDependency } from '@univerjs/ui'; import { useContext } from 'react'; import { BorderLine } from './border-line/BorderLine'; -import { BORDER_LINE_CHILDREN } from './interface'; - -const BORDER_SIZE_CHILDREN = [ - { - label: BorderStyleTypes.THIN, - value: BorderStyleTypes.THIN, - }, - { - label: BorderStyleTypes.HAIR, - value: BorderStyleTypes.HAIR, - }, - { - label: BorderStyleTypes.DOTTED, - value: BorderStyleTypes.DOTTED, - }, - { - label: BorderStyleTypes.DASHED, - value: BorderStyleTypes.DASHED, - }, - { - label: BorderStyleTypes.DASH_DOT, - value: BorderStyleTypes.DASH_DOT, - }, - { - label: BorderStyleTypes.DASH_DOT_DOT, - value: BorderStyleTypes.DASH_DOT_DOT, - }, - { - label: BorderStyleTypes.MEDIUM, - value: BorderStyleTypes.MEDIUM, - }, - { - label: BorderStyleTypes.MEDIUM_DASHED, - value: BorderStyleTypes.MEDIUM_DASHED, - }, - { - label: BorderStyleTypes.MEDIUM_DASH_DOT, - value: BorderStyleTypes.MEDIUM_DASH_DOT, - }, - { - label: BorderStyleTypes.MEDIUM_DASH_DOT_DOT, - value: BorderStyleTypes.MEDIUM_DASH_DOT_DOT, - }, - { - label: BorderStyleTypes.THICK, - value: BorderStyleTypes.THICK, - }, - { - label: BorderStyleTypes.DOUBLE, - value: BorderStyleTypes.DOUBLE, - }, -]; +import { BORDER_LINE_CHILDREN, BORDER_SIZE_CHILDREN } from './interface'; function getBorderColor(borderData: Nullable): string | undefined { if (!borderData) return; diff --git a/packages/sheets-ui/src/views/border-panel/interface.ts b/packages/sheets-ui/src/views/border-panel/interface.ts index b3338311b387..e364d707d122 100644 --- a/packages/sheets-ui/src/views/border-panel/interface.ts +++ b/packages/sheets-ui/src/views/border-panel/interface.ts @@ -16,6 +16,7 @@ import type { IBorderInfo } from '@univerjs/sheets'; import type { ICustomComponentProps } from '@univerjs/ui'; +import { BorderStyleTypes } from '@univerjs/core'; import { COMPONENT_PREFIX } from '../const'; export const BORDER_PANEL_COMPONENT = `${COMPONENT_PREFIX}_BORDER_PANEL_COMPONENT`; @@ -100,3 +101,54 @@ export const BORDER_LINE_CHILDREN = [ value: 'mltr_bctr', }, ]; + +export const BORDER_SIZE_CHILDREN = [ + { + label: BorderStyleTypes.THIN, + value: BorderStyleTypes.THIN, + }, + { + label: BorderStyleTypes.HAIR, + value: BorderStyleTypes.HAIR, + }, + { + label: BorderStyleTypes.DOTTED, + value: BorderStyleTypes.DOTTED, + }, + { + label: BorderStyleTypes.DASHED, + value: BorderStyleTypes.DASHED, + }, + { + label: BorderStyleTypes.DASH_DOT, + value: BorderStyleTypes.DASH_DOT, + }, + { + label: BorderStyleTypes.DASH_DOT_DOT, + value: BorderStyleTypes.DASH_DOT_DOT, + }, + { + label: BorderStyleTypes.MEDIUM, + value: BorderStyleTypes.MEDIUM, + }, + { + label: BorderStyleTypes.MEDIUM_DASHED, + value: BorderStyleTypes.MEDIUM_DASHED, + }, + { + label: BorderStyleTypes.MEDIUM_DASH_DOT, + value: BorderStyleTypes.MEDIUM_DASH_DOT, + }, + { + label: BorderStyleTypes.MEDIUM_DASH_DOT_DOT, + value: BorderStyleTypes.MEDIUM_DASH_DOT_DOT, + }, + { + label: BorderStyleTypes.THICK, + value: BorderStyleTypes.THICK, + }, + { + label: BorderStyleTypes.DOUBLE, + value: BorderStyleTypes.DOUBLE, + }, +]; diff --git a/packages/sheets-ui/src/views/editor-container/EditorContainer.spec.tsx b/packages/sheets-ui/src/views/editor-container/EditorContainer.spec.tsx new file mode 100644 index 000000000000..db943365832b --- /dev/null +++ b/packages/sheets-ui/src/views/editor-container/EditorContainer.spec.tsx @@ -0,0 +1,746 @@ +/** + * 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. + */ + +/** + * @vitest-environment jsdom + */ + +import type { Nullable } from '@univerjs/core'; +import type { ComponentType } from 'react'; +import type { Root } from 'react-dom/client'; +import type { ICellEditorState, IEditorBridgeServiceVisibleParam } from '../../services/editor-bridge.service'; +import type { ICellEditorBoundingClientRect, ICellEditorManagerParam } from '../../services/editor/cell-editor-manager.service'; +import { + DOCS_NORMAL_EDITOR_UNIT_ID_KEY, + ICommandService, + IContextService, + Injector, + IUniverInstanceService, + ThemeService, + UniverInstanceType, +} from '@univerjs/core'; +import { IEditorService } from '@univerjs/docs-ui'; +import { DeviceInputEventType } from '@univerjs/engine-render'; +import { ComponentManager, connectInjector, ILayoutService, ISidebarService } from '@univerjs/ui'; +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { BehaviorSubject, of } from 'rxjs'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +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 { EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE, EMBED_RUNTIME_FOCUS_ROLE_ATTRIBUTE, EmbedInteractionBoundaryService, EmbedRuntimeFocusCoordinator, ISheetEmbedInteractionBoundaryService, ISheetEmbedRuntimeFocusCoordinator } from '../../services/sheet-embed-integration.service'; +import { EditorContainer, shouldRefocusCellEditorAfterPointerDown } from './EditorContainer'; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +let latestFormulaEditorProps: { + disableSelectionOnClick?: boolean; + onFormulaSelectingChange?: (isSelecting: number, isFocusing: boolean) => void; +} | undefined; + +class TestCellEditorManagerService { + private readonly _state$ = new BehaviorSubject>({ + show: true, + startX: 0, + startY: 0, + endX: 120, + endY: 32, + }); + + readonly state$ = this._state$.asObservable(); + readonly rect$ = new BehaviorSubject>(null).asObservable(); + readonly focus$ = new BehaviorSubject(false).asObservable(); + setRect = vi.fn(); + getRect = vi.fn(() => null); + setFocus = vi.fn(); + getState = vi.fn(() => this._state$.getValue()); + setState(param: ICellEditorManagerParam): void { + this._state$.next(param); + } + + dispose(): void { + this._state$.complete(); + } +} + +class TestEditorBridgeService { + private readonly _visible$ = new BehaviorSubject({ + visible: true, + eventType: DeviceInputEventType.Dblclick, + unitId: 'sheet-1', + }); + + readonly visible$ = this._visible$.asObservable(); + readonly currentEditCellState$ = new BehaviorSubject>({ + unitId: 'sheet-1', + sheetId: 'sheet-1', + row: 0, + column: 0, + editorUnitId: DOCS_NORMAL_EDITOR_UNIT_ID_KEY, + documentLayoutObject: {} as never, + }).asObservable(); + + readonly currentEditCellLayout$ = new BehaviorSubject(null).asObservable(); + readonly currentEditCell$ = new BehaviorSubject(null).asObservable(); + readonly forceKeepVisible$ = new BehaviorSubject(false).asObservable(); + readonly helpFunctionVisible$ = new BehaviorSubject(true); + + changeVisible(param: IEditorBridgeServiceVisibleParam): void { + this._visible$.next(param); + } + + isVisible(): IEditorBridgeServiceVisibleParam { + return this._visible$.getValue(); + } + + refreshEditCellState(): void {} + refreshEditCellPosition(): void {} + setEditCell(): void {} + getEditCellState(): null { return null; } + getEditCellLayout(): null { return null; } + getEditLocation(): null { return null; } + updateEditLocation(): void {} + getLatestEditCellState(): null { return null; } + changeEditorDirty(): void {} + getEditorDirty(): boolean { return false; } + enableForceKeepVisible = vi.fn(); + disableForceKeepVisible = vi.fn(); + isForceKeepVisible(): boolean { return false; } + getCurrentEditorId(): string { return DOCS_NORMAL_EDITOR_UNIT_ID_KEY; } + dispose(): void {} +} + +function createTestBed(options: { docSelectionIsFocusing?: boolean; focusedUnitId?: string | null; sheetUnitIds?: string[] } = {}) { + const injector = new Injector(); + const componentManager = new ComponentManager(); + const focusCoordinator = new EmbedRuntimeFocusCoordinator(); + const interactionBoundaryService = new EmbedInteractionBoundaryService(); + const editorBridgeService = new TestEditorBridgeService(); + const cellEditorResizeService = { + resizeCellEditor: vi.fn(), + fitTextSize: vi.fn(), + }; + const docSelectionRenderService = { + isFocusing: options.docSelectionIsFocusing ?? true, + focus: vi.fn(), + }; + + latestFormulaEditorProps = undefined; + componentManager.register(EMBEDDING_FORMULA_EDITOR_COMPONENT_KEY, (props: { + disableSelectionOnClick?: boolean; + onFormulaSelectingChange?: (isSelecting: number, isFocusing: boolean) => void; + }) => { + latestFormulaEditorProps = props; + return
; + }); + injector.add([Injector, injector]); + injector.add([ComponentManager, componentManager]); + injector.add([IEditorBridgeService, { useValue: editorBridgeService as never }]); + injector.add([ICellEditorManagerService, { useClass: TestCellEditorManagerService as never }]); + injector.add([IEditorService, { + useValue: { + getEditor: () => ({ + getBoundingClientRect: () => ({ left: 0, top: 0, width: 120, height: 32 }), + render: { with: () => docSelectionRenderService }, + }), + } as never, + }]); + injector.add([ICommandService, { useValue: { executeCommand: vi.fn(), syncExecuteCommand: vi.fn() } as never }]); + injector.add([IUniverInstanceService, { + useValue: { + getFocusedUnit: () => options.focusedUnitId == null + ? null + : { getUnitId: () => options.focusedUnitId }, + getUnit: (unitId: string, type?: UniverInstanceType) => ( + (type == null || type === UniverInstanceType.UNIVER_SHEET) && + (options.sheetUnitIds ?? ['child-sheet', 'scoped-child-sheet', 'sheet-1']).includes(unitId) + ? { getUnitId: () => unitId } + : null + ), + } as never, + }]); + injector.add([IContextService, { + useValue: { + subscribeContextValue$: () => of(false), + setContextValue: vi.fn(), + getContextValue: vi.fn(), + } as never, + }]); + injector.add([ThemeService, { + useValue: { + darkMode: false, + darkMode$: of(false), + getColorFromTheme: () => '#fff', + } as never, + }]); + injector.add([SheetCellEditorResizeService, { useValue: cellEditorResizeService 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 }; +} + +function renderEditorContainer(root: Root, injector: Injector): void { + const ConnectedTestRoot = connectInjector(EditorContainer, injector) as ComponentType; + root.render(); +} + +describe('EditorContainer embed focus lease', () => { + let root: Root | undefined; + let container: HTMLElement | undefined; + + afterEach(() => { + act(() => root?.unmount()); + container?.remove(); + document.getElementById(`univer-doc-selection-container-${DOCS_NORMAL_EDITOR_UNIT_ID_KEY}`)?.remove(); + document.getElementById(`__editor_${DOCS_NORMAL_EDITOR_UNIT_ID_KEY}`)?.remove(); + vi.useRealTimers(); + root = undefined; + container = undefined; + }); + + it('holds a child-editor lease while the embedded sheet cell editor is visible', async () => { + const { injector, editorBridgeService, focusCoordinator, interactionBoundaryService } = createTestBed(); + const selectionContainer = document.createElement('div'); + selectionContainer.id = `univer-doc-selection-container-${DOCS_NORMAL_EDITOR_UNIT_ID_KEY}`; + const internalEditor = document.createElement('div'); + internalEditor.id = `__editor_${DOCS_NORMAL_EDITOR_UNIT_ID_KEY}`; + selectionContainer.appendChild(internalEditor); + document.body.appendChild(selectionContainer); + container = document.createElement('div'); + container.setAttribute('data-embed-float-dom', 'true'); + container.setAttribute('data-embed-id', 'embed-1'); + container.setAttribute('data-embed-host-unit-id', 'host-doc'); + container.setAttribute('data-embed-child-unit-id', 'child-sheet'); + container.setAttribute(EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE, 'embed-1'); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + renderEditorContainer(root!, injector); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(injector.has(ISheetEmbedRuntimeFocusCoordinator)).toBe(true); + expect(container.querySelector('.univer-absolute')?.closest(`[${EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE}]`)?.getAttribute(EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE)).toBe('embed-1'); + expect(selectionContainer.getAttribute(EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE)).toBe('embed-1'); + expect(internalEditor.getAttribute(EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE)).toBe('embed-1'); + expect(selectionContainer.getAttribute(EMBED_RUNTIME_FOCUS_ROLE_ATTRIBUTE)).toBe('child-editor'); + expect(focusCoordinator.containsElement('embed-1', internalEditor)).toBe(true); + expect(interactionBoundaryService.contains('embed-1', internalEditor)).toBe(true); + expect(focusCoordinator.hasChildInteractionLease('embed-1')).toBe(true); + expect(focusCoordinator.isChildUnitInActiveSession(DOCS_NORMAL_EDITOR_UNIT_ID_KEY)).toBe(true); + expect(focusCoordinator.hasHostPreservingChildFocusLeaseForHost('host-doc')).toBe(true); + expect(focusCoordinator.hasHostPreservingChildFocusLeaseForHost('other-host')).toBe(false); + + await act(async () => { + editorBridgeService.changeVisible({ + visible: false, + eventType: DeviceInputEventType.PointerUp, + unitId: 'sheet-1', + }); + await Promise.resolve(); + }); + + expect(focusCoordinator.hasChildInteractionLease('embed-1')).toBe(false); + expect(focusCoordinator.isChildUnitInActiveSession(DOCS_NORMAL_EDITOR_UNIT_ID_KEY)).toBe(false); + expect(selectionContainer.hasAttribute(EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE)).toBe(false); + selectionContainer.remove(); + }); + + it('claims the app-shell cell editor portal from the registered child runtime scope', async () => { + const { injector, focusCoordinator, interactionBoundaryService } = createTestBed({ + focusedUnitId: 'host-doc', + }); + const runtimeScope = focusCoordinator.registerRuntimeScope({ + embedId: 'embed-1', + hostUnitId: 'host-doc', + childUnitId: 'scoped-child-sheet', + childType: UniverInstanceType.UNIVER_SHEET, + }); + const activeSession = focusCoordinator.acquireLease({ + embedId: 'embed-1', + role: 'child-session', + owner: 'doc-block-stage2-runtime', + hostUnitId: 'host-doc', + childUnitId: 'scoped-child-sheet', + childType: UniverInstanceType.UNIVER_SHEET, + }); + const selectionContainer = document.createElement('div'); + selectionContainer.id = `univer-doc-selection-container-${DOCS_NORMAL_EDITOR_UNIT_ID_KEY}`; + const internalEditor = document.createElement('div'); + internalEditor.id = `__editor_${DOCS_NORMAL_EDITOR_UNIT_ID_KEY}`; + selectionContainer.appendChild(internalEditor); + document.body.appendChild(selectionContainer); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + renderEditorContainer(root!, injector); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(selectionContainer.getAttribute(EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE)).toBe('embed-1'); + expect(internalEditor.getAttribute(EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE)).toBe('embed-1'); + expect(selectionContainer.getAttribute(EMBED_RUNTIME_FOCUS_ROLE_ATTRIBUTE)).toBe('child-editor'); + expect(focusCoordinator.containsElement('embed-1', internalEditor)).toBe(true); + expect(interactionBoundaryService.contains('embed-1', internalEditor)).toBe(true); + expect(focusCoordinator.hasHostPreservingChildFocusLeaseForHost('host-doc')).toBe(true); + + activeSession.dispose(); + runtimeScope.dispose(); + selectionContainer.remove(); + }); + + it('claims the app-shell cell editor portal when the active child session arrives after mount', async () => { + const { injector, focusCoordinator, interactionBoundaryService } = createTestBed({ + focusedUnitId: 'host-doc', + }); + const selectionContainer = document.createElement('div'); + selectionContainer.id = `univer-doc-selection-container-${DOCS_NORMAL_EDITOR_UNIT_ID_KEY}`; + const internalEditor = document.createElement('div'); + internalEditor.id = `__editor_${DOCS_NORMAL_EDITOR_UNIT_ID_KEY}`; + selectionContainer.appendChild(internalEditor); + document.body.appendChild(selectionContainer); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + renderEditorContainer(root!, injector); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(selectionContainer.hasAttribute(EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE)).toBe(false); + + let runtimeScope: ReturnType; + let activeSession: ReturnType; + + await act(async () => { + runtimeScope = focusCoordinator.registerRuntimeScope({ + embedId: 'embed-1', + hostUnitId: 'host-doc', + childUnitId: 'scoped-child-sheet', + childType: UniverInstanceType.UNIVER_SHEET, + }); + activeSession = focusCoordinator.acquireLease({ + embedId: 'embed-1', + role: 'child-session', + owner: 'doc-block-stage2-runtime', + hostUnitId: 'host-doc', + childUnitId: 'scoped-child-sheet', + childType: UniverInstanceType.UNIVER_SHEET, + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(selectionContainer.getAttribute(EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE)).toBe('embed-1'); + expect(internalEditor.getAttribute(EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE)).toBe('embed-1'); + expect(selectionContainer.getAttribute(EMBED_RUNTIME_FOCUS_ROLE_ATTRIBUTE)).toBe('child-editor'); + expect(focusCoordinator.containsElement('embed-1', internalEditor)).toBe(true); + expect(interactionBoundaryService.contains('embed-1', internalEditor)).toBe(true); + + activeSession!.dispose(); + runtimeScope!.dispose(); + selectionContainer.remove(); + }); + + it('focuses the hidden sheet editor when a sheet doc-block session becomes active before the cell editor is visible', async () => { + const { injector, editorBridgeService, focusCoordinator } = createTestBed({ + focusedUnitId: 'host-doc', + sheetUnitIds: ['scoped-child-sheet'], + }); + const selectionContainer = document.createElement('div'); + selectionContainer.id = `univer-doc-selection-container-${DOCS_NORMAL_EDITOR_UNIT_ID_KEY}`; + const internalEditor = document.createElement('div'); + internalEditor.id = `__editor_${DOCS_NORMAL_EDITOR_UNIT_ID_KEY}`; + internalEditor.tabIndex = -1; + selectionContainer.appendChild(internalEditor); + document.body.appendChild(selectionContainer); + const hostCanvas = document.createElement('canvas'); + hostCanvas.tabIndex = -1; + document.body.appendChild(hostCanvas); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + + editorBridgeService.changeVisible({ + visible: false, + eventType: DeviceInputEventType.PointerUp, + unitId: 'scoped-child-sheet', + }); + hostCanvas.focus(); + + await act(async () => { + renderEditorContainer(root!, injector); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + let runtimeScope: ReturnType; + let activeSession: ReturnType; + + await act(async () => { + runtimeScope = focusCoordinator.registerRuntimeScope({ + embedId: 'embed-1', + hostUnitId: 'host-doc', + childUnitId: 'scoped-child-sheet', + childType: UniverInstanceType.UNIVER_SHEET, + }); + activeSession = focusCoordinator.acquireLease({ + embedId: 'embed-1', + role: 'child-session', + owner: 'doc-block-stage2-runtime', + hostUnitId: 'host-doc', + childUnitId: 'scoped-child-sheet', + childType: UniverInstanceType.UNIVER_SHEET, + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(document.activeElement).toBe(internalEditor); + + activeSession!.dispose(); + runtimeScope!.dispose(); + selectionContainer.remove(); + hostCanvas.remove(); + }); + + it('does not focus the hidden sheet editor for an active base doc-block session', async () => { + const { injector, editorBridgeService, focusCoordinator } = createTestBed({ + focusedUnitId: 'host-doc', + sheetUnitIds: ['scoped-child-base'], + }); + const selectionContainer = document.createElement('div'); + selectionContainer.id = `univer-doc-selection-container-${DOCS_NORMAL_EDITOR_UNIT_ID_KEY}`; + const internalEditor = document.createElement('div'); + internalEditor.id = `__editor_${DOCS_NORMAL_EDITOR_UNIT_ID_KEY}`; + internalEditor.tabIndex = -1; + selectionContainer.appendChild(internalEditor); + document.body.appendChild(selectionContainer); + const hostCanvas = document.createElement('canvas'); + hostCanvas.tabIndex = -1; + document.body.appendChild(hostCanvas); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + + editorBridgeService.changeVisible({ + visible: false, + eventType: DeviceInputEventType.PointerUp, + unitId: 'scoped-child-base', + }); + hostCanvas.focus(); + + await act(async () => { + renderEditorContainer(root!, injector); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + const runtimeScope = focusCoordinator.registerRuntimeScope({ + embedId: 'embed-base', + hostUnitId: 'host-doc', + childUnitId: 'scoped-child-base', + childType: UniverInstanceType.UNIVER_BASE, + }); + const activeSession = focusCoordinator.acquireLease({ + embedId: 'embed-base', + role: 'child-session', + owner: 'doc-block-stage2-runtime', + hostUnitId: 'host-doc', + childUnitId: 'scoped-child-base', + childType: UniverInstanceType.UNIVER_BASE, + }); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(document.activeElement).toBe(hostCanvas); + + activeSession.dispose(); + runtimeScope.dispose(); + selectionContainer.remove(); + hostCanvas.remove(); + }); + + it('does not let a stale sheet editor container reclaim the shared app-shell portal from another active embed session', async () => { + const { injector, focusCoordinator } = createTestBed({ + focusedUnitId: 'host-doc', + }); + const sheetScope = focusCoordinator.registerRuntimeScope({ + embedId: 'embed-sheet', + hostUnitId: 'host-doc', + childUnitId: 'child-sheet', + childType: UniverInstanceType.UNIVER_SHEET, + }); + const baseScope = focusCoordinator.registerRuntimeScope({ + embedId: 'embed-base', + hostUnitId: 'host-doc', + childUnitId: 'child-base', + childType: UniverInstanceType.UNIVER_BASE, + }); + const activeBaseSession = focusCoordinator.acquireLease({ + embedId: 'embed-base', + role: 'child-session', + owner: 'doc-block-stage2-runtime', + hostUnitId: 'host-doc', + childUnitId: 'child-base', + childType: UniverInstanceType.UNIVER_BASE, + }); + const selectionContainer = document.createElement('div'); + selectionContainer.id = `univer-doc-selection-container-${DOCS_NORMAL_EDITOR_UNIT_ID_KEY}`; + const internalEditor = document.createElement('div'); + internalEditor.id = `__editor_${DOCS_NORMAL_EDITOR_UNIT_ID_KEY}`; + selectionContainer.appendChild(internalEditor); + document.body.appendChild(selectionContainer); + container = document.createElement('div'); + container.setAttribute('data-embed-float-dom', 'true'); + container.setAttribute('data-embed-id', 'embed-sheet'); + container.setAttribute('data-embed-host-unit-id', 'host-doc'); + container.setAttribute('data-embed-child-unit-id', 'child-sheet'); + container.setAttribute(EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE, 'embed-sheet'); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + renderEditorContainer(root!, injector); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(selectionContainer.getAttribute(EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE)).not.toBe('embed-sheet'); + expect(internalEditor.getAttribute(EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE)).not.toBe('embed-sheet'); + expect(focusCoordinator.containsElement('embed-sheet', internalEditor)).toBe(false); + + activeBaseSession.dispose(); + sheetScope.dispose(); + baseScope.dispose(); + selectionContainer.remove(); + }); + + it('fits the cell editor after the editor container mounts visible', async () => { + const { injector, cellEditorResizeService } = createTestBed(); + container = document.createElement('div'); + container.setAttribute(EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE, 'embed-1'); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + renderEditorContainer(root!, injector); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(cellEditorResizeService.fitTextSize).toHaveBeenCalledTimes(1); + }); + + 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'); + container.setAttribute(EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE, 'embed-1'); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + renderEditorContainer(root!, injector); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + await act(async () => { + latestFormulaEditorProps?.onFormulaSelectingChange?.(1, false); + }); + + expect(editorBridgeService.enableForceKeepVisible).toHaveBeenCalledTimes(1); + expect(editorBridgeService.disableForceKeepVisible).not.toHaveBeenCalled(); + }); + + it('does not override the formula editor click-selection behavior for sheet cell editing', async () => { + const { injector } = createTestBed(); + container = document.createElement('div'); + container.setAttribute(EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE, 'embed-1'); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + renderEditorContainer(root!, injector); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(latestFormulaEditorProps?.disableSelectionOnClick).not.toBe(true); + }); + + it('does not mark the standalone sheet cell editor as an embed child editor', async () => { + const { injector } = createTestBed(); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + renderEditorContainer(root!, injector); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + const editorRoot = container.querySelector('[data-u-comp="editor"]') as HTMLElement; + + expect(editorRoot.getAttribute(EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE)).toBeNull(); + expect(editorRoot.getAttribute(EMBED_RUNTIME_FOCUS_ROLE_ATTRIBUTE)).toBeNull(); + }); + + it('does not keep refocusing the embedded sheet cell editor on delayed timers after it becomes visible', async () => { + const { injector } = createTestBed({ docSelectionIsFocusing: false }); + const setTimeoutSpy = vi.spyOn(window, 'setTimeout'); + container = document.createElement('div'); + container.setAttribute(EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE, 'embed-1'); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + renderEditorContainer(root!, injector); + await Promise.resolve(); + }); + + expect(setTimeoutSpy).not.toHaveBeenCalledWith(expect.any(Function), 80); + expect(setTimeoutSpy).not.toHaveBeenCalledWith(expect.any(Function), 200); + expect(setTimeoutSpy).not.toHaveBeenCalledWith(expect.any(Function), 500); + expect(setTimeoutSpy).not.toHaveBeenCalledWith(expect.any(Function), 1000); + + setTimeoutSpy.mockRestore(); + }); + + it('restores focus to the internal cell editor after pointer down inside the editor canvas', async () => { + const { injector, docSelectionRenderService } = createTestBed({ docSelectionIsFocusing: false }); + const hiddenEditor = document.createElement('div'); + hiddenEditor.id = `__editor_${DOCS_NORMAL_EDITOR_UNIT_ID_KEY}`; + hiddenEditor.tabIndex = -1; + document.body.appendChild(hiddenEditor); + container = document.createElement('div'); + container.setAttribute(EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE, 'embed-1'); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + renderEditorContainer(root!, injector); + await Promise.resolve(); + }); + + const editorRoot = container.querySelector('.univer-absolute') as HTMLElement; + const canvas = document.createElement('canvas'); + canvas.tabIndex = 1; + editorRoot.appendChild(canvas); + canvas.focus(); + + await act(async () => { + editorRoot.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true })); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(docSelectionRenderService.focus).toHaveBeenCalled(); + expect(document.activeElement).toBe(hiddenEditor); + + hiddenEditor.remove(); + }); + + it('does not schedule pointer refocus when the editor is already focused inside the same embed scope', async () => { + vi.useFakeTimers(); + const { injector, docSelectionRenderService } = createTestBed({ docSelectionIsFocusing: true }); + container = document.createElement('div'); + container.setAttribute(EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE, 'embed-1'); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + renderEditorContainer(root!, injector); + await Promise.resolve(); + }); + await act(async () => { + vi.runOnlyPendingTimers(); + await Promise.resolve(); + }); + await act(async () => { + vi.runOnlyPendingTimers(); + await Promise.resolve(); + }); + + const activeEditor = document.createElement('div'); + const hiddenEditor = document.createElement('div'); + hiddenEditor.id = `__editor_${DOCS_NORMAL_EDITOR_UNIT_ID_KEY}`; + hiddenEditor.tabIndex = -1; + activeEditor.setAttribute(EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE, 'embed-1'); + activeEditor.tabIndex = -1; + document.body.appendChild(activeEditor); + document.body.appendChild(hiddenEditor); + activeEditor.focus(); + docSelectionRenderService.focus.mockClear(); + + const editorRoot = container.querySelector('.univer-absolute') as HTMLElement; + editorRoot.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true })); + await act(async () => { + vi.runOnlyPendingTimers(); + await Promise.resolve(); + }); + + expect(docSelectionRenderService.focus).not.toHaveBeenCalled(); + expect(document.activeElement).toBe(activeEditor); + + hiddenEditor.remove(); + activeEditor.remove(); + vi.useRealTimers(); + }); + + it('skips pointer refocus for active elements inside the same embed owner', () => { + const rootElement = document.createElement('div'); + const target = document.createElement('canvas'); + const activeElement = document.createElement('div'); + rootElement.setAttribute(EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE, 'embed-1'); + activeElement.setAttribute(EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE, 'embed-1'); + rootElement.appendChild(target); + + expect(shouldRefocusCellEditorAfterPointerDown({ + root: rootElement, + target, + activeElement, + isEditorFocusing: true, + })).toBe(false); + + activeElement.setAttribute(EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE, 'embed-2'); + expect(shouldRefocusCellEditorAfterPointerDown({ + root: rootElement, + target, + activeElement, + isEditorFocusing: true, + })).toBe(true); + }); + + it('does not refocus while the active cell editor already belongs to the same embed owner', () => { + const rootElement = document.createElement('div'); + const target = document.createElement('canvas'); + const activeElement = document.createElement('div'); + rootElement.setAttribute(EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE, 'embed-1'); + activeElement.setAttribute(EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE, 'embed-1'); + activeElement.setAttribute('data-embed-runtime-focus-role', 'child-editor'); + rootElement.appendChild(target); + + expect(shouldRefocusCellEditorAfterPointerDown({ + root: rootElement, + target, + activeElement, + isEditorFocusing: false, + })).toBe(false); + }); +}); diff --git a/packages/sheets-ui/src/views/editor-container/EditorContainer.tsx b/packages/sheets-ui/src/views/editor-container/EditorContainer.tsx index 014178c139c1..021434e778ed 100644 --- a/packages/sheets-ui/src/views/editor-container/EditorContainer.tsx +++ b/packages/sheets-ui/src/views/editor-container/EditorContainer.tsx @@ -17,16 +17,27 @@ import type { Nullable } from '@univerjs/core'; import type { KeyCode } from '@univerjs/ui'; import type { ICellEditorState } from '../../services/editor-bridge.service'; -import { DOCS_NORMAL_EDITOR_UNIT_ID_KEY, ICommandService, IContextService, ThemeService } from '@univerjs/core'; +import { DisposableCollection, DOCS_NORMAL_EDITOR_UNIT_ID_KEY, FOCUSING_FX_BAR_EDITOR, ICommandService, IContextService, Injector, IUniverInstanceService, ThemeService, toDisposable, UniverInstanceType } from '@univerjs/core'; import { DocSelectionRenderService, IEditorService } from '@univerjs/docs-ui'; import { DeviceInputEventType } from '@univerjs/engine-render'; import { ComponentManager, DISABLE_AUTO_FOCUS_KEY, MetaKeys, useDependency, useEvent, useObservable, useSidebarClick } from '@univerjs/ui'; import * as React from 'react'; -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { SetCellEditVisibleArrowOperation, SetCellEditVisibleOperation } from '../../commands/operations/cell-edit.operation'; 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 { + ISheetEmbedFloatingGeometryService, + ISheetEmbedInteractionBoundaryService, + ISheetEmbedRuntimeFocusCoordinator, + resolveActiveSheetEmbedRuntimeDomScope, + resolveSheetEmbedRuntimeDomScope, + SHEET_EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE, + SHEET_EMBED_RUNTIME_FOCUS_ROLE_ATTRIBUTE, +} from '../../services/sheet-embed-integration.service'; +import { focusSheetCellEditorElement, registerSheetCellEditorRuntimePortal } from './focus-editor'; import { useKeyEventConfig } from './hooks'; interface ICellIEditorProps { } @@ -70,6 +81,101 @@ function isTransparentColor(color: string) { return normalizedColor === 'transparent' || normalizedColor === 'rgba(0,0,0,0)'; } +export function shouldRefocusCellEditorAfterPointerDown(options: { + root: HTMLElement | null | undefined; + target: EventTarget | null | undefined; + activeElement: Element | null | undefined; + isEditorFocusing: boolean | undefined; +}): boolean { + const { root, target, activeElement, isEditorFocusing } = options; + if (!root || !(target instanceof HTMLElement) || !(activeElement instanceof HTMLElement)) { + return true; + } + + const owner = root.closest(`[${SHEET_EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE}]`); + const embedId = owner?.getAttribute(SHEET_EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE); + if (!embedId) { + return true; + } + + const targetInOwner = target.closest(`[${SHEET_EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE}="${embedId}"]`) != null; + const activeOwnerElement = activeElement.closest(`[${SHEET_EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE}="${embedId}"]`); + if (targetInOwner && activeOwnerElement && isEmbedRuntimeInteractiveElement(activeElement)) { + return false; + } + + if (!isEditorFocusing) { + return true; + } + + return target.closest(`[${SHEET_EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE}="${embedId}"]`) == null || + activeOwnerElement == null; +} + +function isEmbedRuntimeInteractiveElement(element: HTMLElement): boolean { + const role = element.getAttribute(SHEET_EMBED_RUNTIME_FOCUS_ROLE_ATTRIBUTE); + + return role === 'child-editor' || + role === 'child-popup' || + role === 'floating-menu'; +} + +function shouldPreserveEmbedPopupFocus(embedId: string | undefined, ownerDocument: Document): boolean { + if (!embedId) { + return false; + } + + const activeElement = ownerDocument.activeElement; + if (!(activeElement instanceof HTMLElement)) { + return false; + } + + const ownerElement = activeElement.closest(`[${SHEET_EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE}="${embedId}"]`); + if (!ownerElement) { + return false; + } + + const role = activeElement.getAttribute(SHEET_EMBED_RUNTIME_FOCUS_ROLE_ATTRIBUTE) ?? + activeElement.closest(`[${SHEET_EMBED_RUNTIME_FOCUS_ROLE_ATTRIBUTE}]`)?.getAttribute(SHEET_EMBED_RUNTIME_FOCUS_ROLE_ATTRIBUTE); + + return role === 'child-popup' || role === 'floating-menu'; +} + +function shouldPreserveEmbedInteractiveFocus(embedId: string | undefined, ownerDocument: Document): boolean { + if (!embedId) { + return false; + } + + const activeElement = ownerDocument.activeElement; + if (!(activeElement instanceof HTMLElement)) { + return false; + } + + const ownerElement = activeElement.closest(`[${SHEET_EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE}="${embedId}"]`); + if (!ownerElement) { + return false; + } + + const role = activeElement.getAttribute(SHEET_EMBED_RUNTIME_FOCUS_ROLE_ATTRIBUTE) ?? + activeElement.closest(`[${SHEET_EMBED_RUNTIME_FOCUS_ROLE_ATTRIBUTE}]`)?.getAttribute(SHEET_EMBED_RUNTIME_FOCUS_ROLE_ATTRIBUTE); + + return (role == null && activeElement.tagName !== 'CANVAS') || + role === 'child-editor' || + role === 'child-popup' || + role === 'floating-menu'; +} + +function isEmbedRuntimeEditorOrPopup(target: EventTarget | null | undefined): boolean { + if (!(target instanceof HTMLElement)) { + return false; + } + + const role = target.getAttribute(SHEET_EMBED_RUNTIME_FOCUS_ROLE_ATTRIBUTE) ?? + target.closest(`[${SHEET_EMBED_RUNTIME_FOCUS_ROLE_ATTRIBUTE}]`)?.getAttribute(SHEET_EMBED_RUNTIME_FOCUS_ROLE_ATTRIBUTE); + + return role === 'child-editor' || role === 'child-popup' || role === 'floating-menu'; +} + /** * Cell editor container. * @returns the rendered cell editor container. @@ -79,12 +185,18 @@ export const EditorContainer: React.FC = () => { ...EDITOR_DEFAULT_POSITION, }); const cellEditorManagerService = useDependency(ICellEditorManagerService); + const injector = useDependency(Injector); const editorService = useDependency(IEditorService); + const instanceService = useDependency(IUniverInstanceService); const contextService = useDependency(IContextService); const themeService = useDependency(ThemeService); const componentManager = useDependency(ComponentManager); const editorBridgeService = useDependency(IEditorBridgeService); + const cellEditorResizeService = useDependency(SheetCellEditorResizeService); + const rootRef = useRef(null); + const pointerRefocusTimerRef = useRef(undefined); const visible = useObservable(editorBridgeService.visible$); + const [runtimeFocusRevision, setRuntimeFocusRevision] = useState(0); const commandService = useDependency(ICommandService); const disableAutoFocus = useObservable( () => contextService.subscribeContextValue$(DISABLE_AUTO_FOCUS_KEY), @@ -138,6 +250,31 @@ export const EditorContainer: React.FC = () => { }; }, []); // Empty dependency array means this effect runs once on mount and clean up on unmount + useEffect(() => { + if (!injector.has(ISheetEmbedFloatingGeometryService)) { + return undefined; + } + + const geometryService = injector.get(ISheetEmbedFloatingGeometryService); + const subscription = geometryService.geometryInvalidated$.subscribe(() => { + cellEditorResizeService.resizeCellEditor(); + }); + + return () => subscription.unsubscribe(); + }, [cellEditorResizeService, injector]); + + useEffect(() => { + if (!injector.has(ISheetEmbedRuntimeFocusCoordinator)) { + return undefined; + } + + const subscription = injector.get(ISheetEmbedRuntimeFocusCoordinator).runtimeSessionChanged$.subscribe(() => { + setRuntimeFocusRevision((revision) => revision + 1); + }); + + return () => subscription.unsubscribe(); + }, [injector]); + useEffect(() => { if (!disableAutoFocus) { cellEditorManagerService.setFocus(true); @@ -149,16 +286,56 @@ export const EditorContainer: React.FC = () => { return; } + cellEditorResizeService.fitTextSize(); + if (contextService.getContextValue(FOCUSING_FX_BAR_EDITOR)) { + return; + } + + const ownerDocument = rootRef.current?.ownerDocument ?? document; + const ownerWindow = ownerDocument.defaultView ?? window; let focusRetryFrame = 0; let finalFocusRetryFrame = 0; + let delayedFocusTimer: number | undefined; + const focusCellEditorElement = () => { + const scope = rootRef.current + ? resolveSheetEmbedRuntimeDomScope(rootRef.current) ?? resolveActiveSheetEmbedRuntimeDomScope(ownerDocument) + : resolveActiveSheetEmbedRuntimeDomScope(ownerDocument); + if (shouldPreserveEmbedInteractiveFocus(scope?.embedId, ownerDocument)) { + return; + } + focusSheetCellEditorElement(ownerDocument); + if (delayedFocusTimer != null) { + ownerWindow.clearTimeout(delayedFocusTimer); + } + delayedFocusTimer = ownerWindow.setTimeout(() => { + delayedFocusTimer = undefined; + if (shouldPreserveEmbedInteractiveFocus(scope?.embedId, ownerDocument)) { + return; + } + focusSheetCellEditorElement(ownerDocument); + }, 0); + }; const focusEditor = () => { + if (contextService.getContextValue(FOCUSING_FX_BAR_EDITOR)) { + return; + } + + const scope = rootRef.current + ? resolveSheetEmbedRuntimeDomScope(rootRef.current) ?? resolveActiveSheetEmbedRuntimeDomScope(ownerDocument) + : resolveActiveSheetEmbedRuntimeDomScope(ownerDocument); + if (shouldPreserveEmbedPopupFocus(scope?.embedId, ownerDocument)) { + return; + } + const editor = editorService.getEditor(DOCS_NORMAL_EDITOR_UNIT_ID_KEY); const docSelectionRenderService = editor?.render.with(DocSelectionRenderService); if (!docSelectionRenderService?.isFocusing) { docSelectionRenderService?.focus(); } + + focusCellEditorElement(); }; focusEditor(); @@ -170,8 +347,242 @@ export const EditorContainer: React.FC = () => { return () => { cancelAnimationFrame(focusRetryFrame); cancelAnimationFrame(finalFocusRetryFrame); + if (delayedFocusTimer != null) { + ownerWindow.clearTimeout(delayedFocusTimer); + } + }; + }, [cellEditorResizeService, editorService, visible?.visible]); + + useEffect(() => { + if (!visible?.visible || !rootRef.current || !injector.has(ISheetEmbedRuntimeFocusCoordinator)) { + return undefined; + } + + const focusCoordinator = injector.get(ISheetEmbedRuntimeFocusCoordinator); + const focusedUnitId = instanceService.getFocusedUnit()?.getUnitId(); + const rootRuntimeScope = resolveSheetEmbedRuntimeDomScope(rootRef.current); + const unitRuntimeScope = [editState?.unitId, visible.unitId, focusedUnitId] + .map((unitId) => focusCoordinator.resolveRuntimeScopeByChildUnitId(unitId)) + .find((resolvedScope) => resolvedScope != null); + if (rootRuntimeScope && unitRuntimeScope && rootRuntimeScope.embedId !== unitRuntimeScope.embedId) { + return undefined; + } + + const activeSessionScope = focusCoordinator.resolveActiveChildSessionRuntimeScope(); + const explicitRuntimeScope = unitRuntimeScope ?? rootRuntimeScope; + if (activeSessionScope && rootRuntimeScope && !unitRuntimeScope && rootRuntimeScope.embedId !== activeSessionScope.embedId) { + return undefined; + } + + const scope = explicitRuntimeScope ?? + activeSessionScope ?? + resolveActiveSheetEmbedRuntimeDomScope(rootRef.current.ownerDocument); + if (!scope) { + return undefined; + } + + const collection = new DisposableCollection(); + const interactionBoundaryService = injector.has(ISheetEmbedInteractionBoundaryService) + ? injector.get(ISheetEmbedInteractionBoundaryService) + : undefined; + const editorRoot = rootRef.current; + collection.add(focusCoordinator.acquireLease({ + embedId: scope.embedId, + role: 'child-editor', + owner: 'sheet-cell-editor', + hostUnitId: scope.hostUnitId, + childUnitId: scope.childUnitId, + associatedChildUnitIds: [DOCS_NORMAL_EDITOR_UNIT_ID_KEY], + })); + if (interactionBoundaryService) { + collection.add(interactionBoundaryService.registerOwnedElement(scope.embedId, editorRoot)); + } + collection.add(focusCoordinator.registerElement({ + embedId: scope.embedId, + role: 'child-editor', + element: editorRoot, + })); + collection.add(registerSheetCellEditorRuntimePortal({ + embedId: scope.embedId, + ownerDocument: rootRef.current.ownerDocument, + interactionBoundaryService, + focusCoordinator, + })); + if (scope.childType === UniverInstanceType.UNIVER_SHEET && scope.childUnitId) { + const ownerDocument = rootRef.current.ownerDocument; + let pointerRetryFrame = 0; + const focusEditor = () => { + if (contextService.getContextValue(FOCUSING_FX_BAR_EDITOR) || shouldPreserveEmbedPopupFocus(scope.embedId, ownerDocument)) { + return; + } + + const editor = editorService.getEditor(DOCS_NORMAL_EDITOR_UNIT_ID_KEY); + const docSelectionRenderService = editor?.render.with(DocSelectionRenderService); + + if (!docSelectionRenderService?.isFocusing) { + docSelectionRenderService?.focus(); + } + + focusSheetCellEditorElement(ownerDocument); + }; + const refocusEditorAfterRuntimePointer = (event: PointerEvent | MouseEvent) => { + if (!focusCoordinator.isChildUnitRuntimeEvent(scope.childUnitId, event.target, event) || isEmbedRuntimeEditorOrPopup(event.target)) { + return; + } + + cancelAnimationFrame(pointerRetryFrame); + pointerRetryFrame = requestAnimationFrame(focusEditor); + }; + ownerDocument.addEventListener('pointerdown', refocusEditorAfterRuntimePointer, true); + ownerDocument.addEventListener('pointerup', refocusEditorAfterRuntimePointer, true); + ownerDocument.addEventListener('click', refocusEditorAfterRuntimePointer, true); + collection.add(toDisposable(() => { + cancelAnimationFrame(pointerRetryFrame); + ownerDocument.removeEventListener('pointerdown', refocusEditorAfterRuntimePointer, true); + ownerDocument.removeEventListener('pointerup', refocusEditorAfterRuntimePointer, true); + ownerDocument.removeEventListener('click', refocusEditorAfterRuntimePointer, true); + })); + } + + return () => collection.dispose(); + }, [contextService, editState?.unitId, editorService, injector, instanceService, runtimeFocusRevision, visible?.unitId, visible?.visible]); + + useEffect(() => { + if (visible?.visible || !injector.has(ISheetEmbedRuntimeFocusCoordinator)) { + return undefined; + } + + const focusCoordinator = injector.get(ISheetEmbedRuntimeFocusCoordinator); + const activeSessionScope = focusCoordinator.resolveActiveChildSessionRuntimeScope(); + const childUnitId = activeSessionScope?.childUnitId; + if ( + !activeSessionScope || + activeSessionScope.childType !== UniverInstanceType.UNIVER_SHEET || + !childUnitId || + !instanceService.getUnit(childUnitId, UniverInstanceType.UNIVER_SHEET) + ) { + return undefined; + } + + const ownerDocument = rootRef.current?.ownerDocument ?? document; + const interactionBoundaryService = injector.has(ISheetEmbedInteractionBoundaryService) + ? injector.get(ISheetEmbedInteractionBoundaryService) + : undefined; + const portalRegistration = registerSheetCellEditorRuntimePortal({ + embedId: activeSessionScope.embedId, + ownerDocument, + interactionBoundaryService, + focusCoordinator, + }); + const ownerWindow = ownerDocument.defaultView ?? window; + let delayedFocusTimer: number | undefined; + const focusCellEditorElement = () => { + if (shouldPreserveEmbedInteractiveFocus(activeSessionScope.embedId, ownerDocument)) { + return; + } + + focusSheetCellEditorElement(ownerDocument); + if (delayedFocusTimer != null) { + ownerWindow.clearTimeout(delayedFocusTimer); + } + delayedFocusTimer = ownerWindow.setTimeout(() => { + delayedFocusTimer = undefined; + if (shouldPreserveEmbedInteractiveFocus(activeSessionScope.embedId, ownerDocument)) { + return; + } + focusSheetCellEditorElement(ownerDocument); + }, 0); }; - }, [editorService, visible?.visible]); + const focusHiddenEditor = () => { + if (shouldPreserveEmbedPopupFocus(activeSessionScope.embedId, ownerDocument)) { + return; + } + + const editor = editorService.getEditor(DOCS_NORMAL_EDITOR_UNIT_ID_KEY); + const docSelectionRenderService = editor?.render.with(DocSelectionRenderService); + if (!docSelectionRenderService?.isFocusing) { + docSelectionRenderService?.focus(); + } + + focusCellEditorElement(); + }; + let retryFrame = 0; + let finalRetryFrame = 0; + let pointerRetryFrame = 0; + + focusHiddenEditor(); + retryFrame = requestAnimationFrame(() => { + focusHiddenEditor(); + finalRetryFrame = requestAnimationFrame(focusHiddenEditor); + }); + const refocusHiddenEditorAfterRuntimePointer = (event: PointerEvent | MouseEvent) => { + if (!focusCoordinator.isChildUnitRuntimeEvent(childUnitId, event.target, event) || isEmbedRuntimeEditorOrPopup(event.target)) { + return; + } + + cancelAnimationFrame(pointerRetryFrame); + pointerRetryFrame = requestAnimationFrame(focusHiddenEditor); + }; + ownerDocument.addEventListener('pointerdown', refocusHiddenEditorAfterRuntimePointer, true); + ownerDocument.addEventListener('pointerup', refocusHiddenEditorAfterRuntimePointer, true); + ownerDocument.addEventListener('click', refocusHiddenEditorAfterRuntimePointer, true); + + return () => { + cancelAnimationFrame(retryFrame); + cancelAnimationFrame(finalRetryFrame); + cancelAnimationFrame(pointerRetryFrame); + if (delayedFocusTimer != null) { + ownerWindow.clearTimeout(delayedFocusTimer); + } + ownerDocument.removeEventListener('pointerdown', refocusHiddenEditorAfterRuntimePointer, true); + ownerDocument.removeEventListener('pointerup', refocusHiddenEditorAfterRuntimePointer, true); + ownerDocument.removeEventListener('click', refocusHiddenEditorAfterRuntimePointer, true); + portalRegistration.dispose(); + }; + }, [editorService, injector, instanceService, runtimeFocusRevision, visible?.visible]); + + useEffect(() => { + return () => { + const ownerWindow = rootRef.current?.ownerDocument.defaultView; + if (ownerWindow && pointerRefocusTimerRef.current != null) { + ownerWindow.clearTimeout(pointerRefocusTimerRef.current); + } + }; + }, []); + + const refocusEditorAfterPointerDown = useEvent((event: React.PointerEvent) => { + if (!visible?.visible) { + return; + } + + const ownerDocument = rootRef.current?.ownerDocument ?? document; + const ownerWindow = ownerDocument.defaultView ?? window; + const editor = editorService.getEditor(DOCS_NORMAL_EDITOR_UNIT_ID_KEY); + const docSelectionRenderService = editor?.render.with(DocSelectionRenderService); + const pointerTarget = event.target; + const activeElementAtPointerDown = ownerDocument.activeElement; + if (!shouldRefocusCellEditorAfterPointerDown({ + root: rootRef.current, + target: pointerTarget, + activeElement: activeElementAtPointerDown, + isEditorFocusing: docSelectionRenderService?.isFocusing, + })) { + return; + } + + if (pointerRefocusTimerRef.current != null) { + ownerWindow.clearTimeout(pointerRefocusTimerRef.current); + } + + pointerRefocusTimerRef.current = ownerWindow.setTimeout(() => { + pointerRefocusTimerRef.current = undefined; + if (!docSelectionRenderService?.isFocusing) { + docSelectionRenderService?.focus(); + } + + focusSheetCellEditorElement(ownerDocument); + }, 0); + }); const handleClickSideBar = useEvent(() => { if (editorBridgeService.isVisible().visible) { @@ -199,7 +610,10 @@ export const EditorContainer: React.FC = () => { return (
= () => { resetSelectionOnBlur={false} isSingle={false} autoScrollbar={false} - onFormulaSelectingChange={(isSelecting: 0 | 1 | 2, isFocusing: boolean) => { - if (!isFocusing) return; + onFormulaSelectingChange={(isSelecting: 0 | 1 | 2) => { if (isSelecting) { editorBridgeService.enableForceKeepVisible(); } else { editorBridgeService.disableForceKeepVisible(); } }} - disableSelectionOnClick disableContextMenu={false} canvasStyle={{ backgroundColor: 'transparent' }} /> diff --git a/packages/sheets-ui/src/views/editor-container/focus-editor.spec.ts b/packages/sheets-ui/src/views/editor-container/focus-editor.spec.ts new file mode 100644 index 000000000000..0497ddab864b --- /dev/null +++ b/packages/sheets-ui/src/views/editor-container/focus-editor.spec.ts @@ -0,0 +1,129 @@ +/** + * 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. + */ + +/** + * @vitest-environment jsdom + */ + +import { EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE, EMBED_RUNTIME_FOCUS_ROLE_ATTRIBUTE, EmbedInteractionBoundaryService, EmbedRuntimeFocusCoordinator } from '../../services/sheet-embed-integration.service'; +import { afterEach, describe, expect, it } from 'vitest'; +import { focusSheetCellEditorElement, registerSheetCellEditorRuntimePortal, resolveSheetCellEditorPortalRoot } from './focus-editor'; + +describe('focusSheetCellEditorElement', () => { + afterEach(() => { + document.body.replaceChildren(); + }); + + it('focuses the sheet cell editor DOM node', () => { + const hostEditor = document.createElement('div'); + hostEditor.id = '__editor_docs-embed-host'; + hostEditor.tabIndex = -1; + const cellEditor = document.createElement('div'); + cellEditor.id = '__editor___INTERNAL_EDITOR__DOCS_NORMAL'; + cellEditor.tabIndex = -1; + document.body.append(hostEditor, cellEditor); + hostEditor.focus(); + + expect(focusSheetCellEditorElement(document)).toBe(true); + + expect(document.activeElement).toBe(cellEditor); + }); + + it('makes the sheet cell editor focusable when it has no tabindex', () => { + const hostEditor = document.createElement('div'); + hostEditor.id = '__editor_docs-embed-host'; + hostEditor.tabIndex = -1; + const cellEditor = document.createElement('div'); + cellEditor.id = '__editor___INTERNAL_EDITOR__DOCS_NORMAL'; + document.body.append(hostEditor, cellEditor); + hostEditor.focus(); + + expect(focusSheetCellEditorElement(document)).toBe(true); + + expect(cellEditor.tabIndex).toBe(-1); + expect(document.activeElement).toBe(cellEditor); + }); + + it('registers the sheet cell editor portal as an owned child editor while embedded', () => { + const selectionContainer = document.createElement('div'); + selectionContainer.id = 'univer-doc-selection-container-__INTERNAL_EDITOR__DOCS_NORMAL'; + const cellEditor = document.createElement('div'); + cellEditor.id = '__editor___INTERNAL_EDITOR__DOCS_NORMAL'; + selectionContainer.appendChild(cellEditor); + document.body.appendChild(selectionContainer); + const interactionBoundaryService = new EmbedInteractionBoundaryService(); + const focusCoordinator = new EmbedRuntimeFocusCoordinator(); + + const disposable = registerSheetCellEditorRuntimePortal({ + embedId: 'embed-1', + interactionBoundaryService, + focusCoordinator, + }); + + expect(resolveSheetCellEditorPortalRoot(document)).toBe(selectionContainer); + expect(selectionContainer.getAttribute(EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE)).toBe('embed-1'); + expect(cellEditor.getAttribute(EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE)).toBe('embed-1'); + expect(selectionContainer.getAttribute(EMBED_RUNTIME_FOCUS_ROLE_ATTRIBUTE)).toBe('child-editor'); + expect(cellEditor.getAttribute(EMBED_RUNTIME_FOCUS_ROLE_ATTRIBUTE)).toBe('child-editor'); + expect(interactionBoundaryService.contains('embed-1', cellEditor)).toBe(true); + expect(focusCoordinator.containsElement('embed-1', cellEditor)).toBe(true); + + disposable.dispose(); + + expect(selectionContainer.hasAttribute(EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE)).toBe(false); + expect(cellEditor.hasAttribute(EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE)).toBe(false); + expect(selectionContainer.hasAttribute(EMBED_RUNTIME_FOCUS_ROLE_ATTRIBUTE)).toBe(false); + expect(cellEditor.hasAttribute(EMBED_RUNTIME_FOCUS_ROLE_ATTRIBUTE)).toBe(false); + }); + + it('keeps ownership when the sheet cell editor portal is remounted while embedded', async () => { + const selectionContainer = document.createElement('div'); + selectionContainer.id = 'univer-doc-selection-container-__INTERNAL_EDITOR__DOCS_NORMAL'; + const cellEditor = document.createElement('div'); + cellEditor.id = '__editor___INTERNAL_EDITOR__DOCS_NORMAL'; + selectionContainer.appendChild(cellEditor); + document.body.appendChild(selectionContainer); + const interactionBoundaryService = new EmbedInteractionBoundaryService(); + const focusCoordinator = new EmbedRuntimeFocusCoordinator(); + + const disposable = registerSheetCellEditorRuntimePortal({ + embedId: 'embed-1', + interactionBoundaryService, + focusCoordinator, + }); + + selectionContainer.remove(); + const nextSelectionContainer = document.createElement('div'); + nextSelectionContainer.id = 'univer-doc-selection-container-__INTERNAL_EDITOR__DOCS_NORMAL'; + const nextCellEditor = document.createElement('div'); + nextCellEditor.id = '__editor___INTERNAL_EDITOR__DOCS_NORMAL'; + nextSelectionContainer.appendChild(nextCellEditor); + document.body.appendChild(nextSelectionContainer); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(nextSelectionContainer.getAttribute(EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE)).toBe('embed-1'); + expect(nextCellEditor.getAttribute(EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE)).toBe('embed-1'); + expect(nextSelectionContainer.getAttribute(EMBED_RUNTIME_FOCUS_ROLE_ATTRIBUTE)).toBe('child-editor'); + expect(nextCellEditor.getAttribute(EMBED_RUNTIME_FOCUS_ROLE_ATTRIBUTE)).toBe('child-editor'); + expect(interactionBoundaryService.contains('embed-1', nextCellEditor)).toBe(true); + expect(focusCoordinator.containsElement('embed-1', nextCellEditor)).toBe(true); + + disposable.dispose(); + + expect(nextSelectionContainer.hasAttribute(EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE)).toBe(false); + expect(nextCellEditor.hasAttribute(EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE)).toBe(false); + }); +}); diff --git a/packages/sheets-ui/src/views/editor-container/focus-editor.ts b/packages/sheets-ui/src/views/editor-container/focus-editor.ts new file mode 100644 index 000000000000..59294bd646c5 --- /dev/null +++ b/packages/sheets-ui/src/views/editor-container/focus-editor.ts @@ -0,0 +1,143 @@ +/** + * 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 type { IDisposable } from '@univerjs/core'; +import { DisposableCollection, DOCS_NORMAL_EDITOR_UNIT_ID_KEY, toDisposable } from '@univerjs/core'; +import type { ISheetEmbedInteractionBoundaryService, ISheetEmbedRuntimeFocusCoordinator } from '../../services/sheet-embed-integration.service'; + +const SHEET_CELL_EDITOR_ELEMENT_ID = `__editor_${DOCS_NORMAL_EDITOR_UNIT_ID_KEY}`; +const SHEET_CELL_EDITOR_SELECTION_CONTAINER_ID = `univer-doc-selection-container-${DOCS_NORMAL_EDITOR_UNIT_ID_KEY}`; + +export function focusSheetCellEditorElement(ownerDocument: Document = document): boolean { + const element = ownerDocument.getElementById(SHEET_CELL_EDITOR_ELEMENT_ID) as HTMLElement | null; + + if (element == null || ownerDocument.activeElement === element) { + return false; + } + + if (!element.hasAttribute('tabindex')) { + element.tabIndex = -1; + } + + element.focus({ preventScroll: true }); + + return ownerDocument.activeElement === element; +} + +export function registerSheetCellEditorRuntimePortal(options: { + embedId: string; + ownerDocument?: Document; + interactionBoundaryService?: ISheetEmbedInteractionBoundaryService; + focusCoordinator?: ISheetEmbedRuntimeFocusCoordinator; +}): IDisposable { + const ownerDocument = options.ownerDocument ?? (typeof document === 'undefined' ? undefined : document); + if (!ownerDocument) { + return toDisposable(() => {}); + } + + const collection = new DisposableCollection(); + const view = ownerDocument.defaultView; + let disposed = false; + let registeredPortalRoot: HTMLElement | null = null; + let portalRegistration: IDisposable | undefined; + const frameHandles: number[] = []; + let observer: MutationObserver | undefined; + const tryRegister = () => { + if (disposed) { + return; + } + + const portalRoot = resolveSheetCellEditorPortalRoot(ownerDocument); + if (portalRoot === registeredPortalRoot) { + return; + } + + portalRegistration?.dispose(); + portalRegistration = undefined; + registeredPortalRoot = null; + if (!portalRoot) { + return; + } + + const rootRegistration = new DisposableCollection(); + registeredPortalRoot = portalRoot; + if (options.interactionBoundaryService) { + rootRegistration.add(options.interactionBoundaryService.registerOwnedElement(options.embedId, portalRoot)); + } + + if (options.focusCoordinator) { + rootRegistration.add(options.focusCoordinator.registerElement({ + embedId: options.embedId, + role: 'child-editor', + element: portalRoot, + })); + + const editorElement = ownerDocument.getElementById(SHEET_CELL_EDITOR_ELEMENT_ID) as HTMLElement | null; + if (editorElement && editorElement !== portalRoot) { + rootRegistration.add(options.focusCoordinator.registerElement({ + embedId: options.embedId, + role: 'child-editor', + element: editorElement, + })); + } + } + portalRegistration = rootRegistration; + }; + const scheduleRetry = (remaining: number) => { + if (remaining <= 0 || !view?.requestAnimationFrame) { + return; + } + + const handle = view.requestAnimationFrame(() => { + const index = frameHandles.indexOf(handle); + if (index >= 0) { + frameHandles.splice(index, 1); + } + tryRegister(); + if (!registeredPortalRoot) { + scheduleRetry(remaining - 1); + } + }); + frameHandles.push(handle); + }; + + tryRegister(); + if (!registeredPortalRoot) { + scheduleRetry(2); + } + if (view?.MutationObserver && ownerDocument.body) { + observer = new view.MutationObserver(() => tryRegister()); + observer.observe(ownerDocument.body, { childList: true, subtree: true }); + } + + collection.add(toDisposable(() => { + disposed = true; + frameHandles.forEach((handle) => view?.cancelAnimationFrame?.(handle)); + frameHandles.length = 0; + observer?.disconnect(); + observer = undefined; + portalRegistration?.dispose(); + portalRegistration = undefined; + registeredPortalRoot = null; + })); + + return collection; +} + +export function resolveSheetCellEditorPortalRoot(ownerDocument: Document = document): HTMLElement | null { + return (ownerDocument.getElementById(SHEET_CELL_EDITOR_SELECTION_CONTAINER_ID) as HTMLElement | null) + ?? (ownerDocument.getElementById(SHEET_CELL_EDITOR_ELEMENT_ID) as HTMLElement | null); +} diff --git a/packages/sheets-ui/src/views/formula-bar/FormulaBar.tsx b/packages/sheets-ui/src/views/formula-bar/FormulaBar.tsx index 26a540cdd96d..bc2c3047248b 100644 --- a/packages/sheets-ui/src/views/formula-bar/FormulaBar.tsx +++ b/packages/sheets-ui/src/views/formula-bar/FormulaBar.tsx @@ -255,11 +255,12 @@ export function FormulaBar(props: IProps) { const handlePointerDown = () => { try { + contextService.setContextValue(FOCUSING_FX_BAR_EDITOR, true); + // When clicking on the formula bar, the cell editor also needs to enter the edit state const visibleState = editorBridgeService.isVisible(); if (visibleState.visible === false) { if (editorActivationDisable) { - contextService.setContextValue(FOCUSING_FX_BAR_EDITOR, true); editorService.focus(DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY); return; } @@ -274,15 +275,13 @@ export function FormulaBar(props: IProps) { ); // cancel by event if (!result) { + contextService.setContextValue(FOCUSING_FX_BAR_EDITOR, false); shouldSkipFocus.current = true; - return; } // undoRedoService.clearUndoRedo(DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY); } - - // Open the normal editor first, and then we mark formula editor as activated. - contextService.setContextValue(FOCUSING_FX_BAR_EDITOR, true); } catch (e) { + contextService.setContextValue(FOCUSING_FX_BAR_EDITOR, false); shouldSkipFocus.current = true; throw e; } diff --git a/packages/sheets-ui/src/views/sheet-bar/sheet-bar-tabs/SheetBarItem.tsx b/packages/sheets-ui/src/views/sheet-bar/sheet-bar-tabs/SheetBarItem.tsx index 1aa638ac3d14..5723e99e0455 100644 --- a/packages/sheets-ui/src/views/sheet-bar/sheet-bar-tabs/SheetBarItem.tsx +++ b/packages/sheets-ui/src/views/sheet-bar/sheet-bar-tabs/SheetBarItem.tsx @@ -15,7 +15,7 @@ */ import type { BooleanNumber } from '@univerjs/core'; -import type { CSSProperties, KeyboardEventHandler, ReactNode } from 'react'; +import type { CSSProperties, KeyboardEventHandler, MouseEventHandler, ReactNode } from 'react'; import { ColorKit, ThemeService } from '@univerjs/core'; import { clsx } from '@univerjs/design'; import { useDependency } from '@univerjs/ui'; @@ -32,11 +32,12 @@ export interface IBaseSheetBarProps { menuOverlay?: ReactNode; className?: string; onKeyDown?: KeyboardEventHandler; + onClick?: MouseEventHandler; tabIndex?: number; } export function SheetBarItem(props: IBaseSheetBarProps) { - const { sheetId, label, color, selected, className, onKeyDown, tabIndex } = props; + const { sheetId, label, color, selected, className, onKeyDown, onClick, tabIndex } = props; const themeService = useDependency(ThemeService); @@ -58,6 +59,7 @@ export function SheetBarItem(props: IBaseSheetBarProps) { aria-selected={currentSelected} tabIndex={tabIndex ?? (currentSelected ? 0 : -1)} onKeyDown={onKeyDown} + onClick={onClick} className={clsx(` univer-mx-1 univer-box-border univer-flex univer-flex-grow univer-cursor-pointer univer-select-none univer-flex-row univer-items-center univer-rounded univer-text-xs univer-transition-[colors,box-shadow] diff --git a/packages/sheets-ui/src/views/sheet-bar/sheet-bar-tabs/SheetBarTabs.tsx b/packages/sheets-ui/src/views/sheet-bar/sheet-bar-tabs/SheetBarTabs.tsx index 879af075e9ea..0364ae3ccdf3 100644 --- a/packages/sheets-ui/src/views/sheet-bar/sheet-bar-tabs/SheetBarTabs.tsx +++ b/packages/sheets-ui/src/views/sheet-bar/sheet-bar-tabs/SheetBarTabs.tsx @@ -28,6 +28,7 @@ import { nameCharacterCheck, Quantity, } from '@univerjs/core'; +import { IRenderManagerService } from '@univerjs/engine-render'; import { LockIcon } from '@univerjs/icons'; import { InsertSheetMutation, @@ -49,6 +50,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { merge } from 'rxjs'; import { IEditorBridgeService } from '../../../services/editor-bridge.service'; import { ISheetBarService } from '../../../services/sheet-bar/sheet-bar.service'; +import { SheetSkeletonManagerService } from '../../../services/sheet-skeleton-manager.service'; import { useActiveWorkbook } from '../../hook'; import { SheetBarItem } from './SheetBarItem'; import { SheetBarTabsContextMenu } from './SheetBarTabsContextMenu'; @@ -107,8 +109,10 @@ export function SheetBarTabs() { const slideTabBarRef = useRef(null); const slideTabBarContainerRef = useRef(null); + const activeSheetIdRef = useRef(activeSheetId); const commandService = useDependency(ICommandService); + const renderManagerService = useDependency(IRenderManagerService); const sheetBarService = useDependency(ISheetBarService); const localeService = useDependency(LocaleService); const confirmService = useDependency(IConfirmService); @@ -118,6 +122,7 @@ export function SheetBarTabs() { const permissionService = useDependency(IPermissionService); const workbook = useActiveWorkbook()!; + const workbookRef = useRef(workbook); const resetOrder = useObservable(worksheetProtectionRuleModel.resetOrder$); const config = useConfigValue(UI_PLUGIN_CONFIG_KEY); const showContextMenu = config?.contextMenu ?? true; @@ -289,6 +294,46 @@ export function SheetBarTabs() { }); }, [sheetBarService]); + useEffect(() => { + activeSheetIdRef.current = activeSheetId; + }, [activeSheetId]); + + useEffect(() => { + workbookRef.current = workbook; + }, [workbook]); + + const syncActiveSheetRender = useCallback((subUnitId: string) => { + const render = renderManagerService.getRenderById(workbookRef.current.getUnitId()); + try { + render?.with(SheetSkeletonManagerService).setCurrent({ sheetId: subUnitId }); + render?.scene.makeDirty(true); + render?.scene.render(); + } catch { + // The normal command path owns render updates. This fallback only runs when that path was skipped. + } + }, [renderManagerService]); + + const activateSheetTab = useCallback((subUnitId?: string) => { + if (!subUnitId || subUnitId === activeSheetIdRef.current) { + return; + } + + void commandService.executeCommand(SetWorksheetActiveOperation.id, { + subUnitId, + unitId: workbookRef.current.getUnitId(), + }).then((result) => { + if (result !== false) { + return; + } + + const worksheet = workbookRef.current.getSheetBySheetId(subUnitId); + if (worksheet) { + workbookRef.current.setActiveSheet(worksheet); + syncActiveSheetRender(subUnitId); + } + }); + }, [commandService, syncActiveSheetRender]); + const observeResize = useCallback((slideTabBar: SlideTabBar) => { const slideTabBarContainer = slideTabBarContainerRef.current?.querySelector('[data-u-comp=slide-tab-bar]'); if (!slideTabBarContainer) { @@ -514,6 +559,9 @@ export function SheetBarTabs() { const renameSubscription = sheetBarService.renameId$.subscribe(() => { setTabEditor(); }); + const activeSheetSubscription = workbook.activeSheet$.subscribe(() => { + updateSheetItems(); + }); return () => { commandDisposable.dispose(); @@ -522,6 +570,7 @@ export function SheetBarTabs() { scrollSubscription.unsubscribe(); scrollXSubscription.unsubscribe(); renameSubscription.unsubscribe(); + activeSheetSubscription.unsubscribe(); disconnectResizeObserver?.(); }; }, [commandService, initializeSlideTabBar, resetOrder, setTabEditor, sheetBarService, syncScrollState, updateSheetItems, workbook]); @@ -535,6 +584,26 @@ export function SheetBarTabs() { slideTabBarRef.current?.update(currentIndex >= 0 ? currentIndex : 0); }, [activeSheetId, sheetList]); + useEffect(() => { + const handlePointerDownCapture = (event: PointerEvent) => { + const container = slideTabBarContainerRef.current; + const target = event.target; + if (!container || !(target instanceof Element)) { + return; + } + + const tabElement = target.closest('[data-u-comp=slide-tab-item]'); + if (!tabElement || !container.contains(tabElement)) { + return; + } + + activateSheetTab(tabElement.getAttribute('data-id') ?? undefined); + }; + + document.addEventListener('pointerdown', handlePointerDownCapture, true); + return () => document.removeEventListener('pointerdown', handlePointerDownCapture, true); + }, [activateSheetTab]); + useEffect(() => { const subscription = merge( worksheetProtectionRuleModel.ruleChange$, diff --git a/packages/sheets-ui/src/views/sheet-bar/sheet-bar-tabs/utils/slide-tab-bar.ts b/packages/sheets-ui/src/views/sheet-bar/sheet-bar-tabs/utils/slide-tab-bar.ts index 5a27662c0ec8..b007a6a6e4b1 100644 --- a/packages/sheets-ui/src/views/sheet-bar/sheet-bar-tabs/utils/slide-tab-bar.ts +++ b/packages/sheets-ui/src/views/sheet-bar/sheet-bar-tabs/utils/slide-tab-bar.ts @@ -425,7 +425,6 @@ export class SlideTabBar { if (this._activeTabItemIndex !== slideItemIndex) { this._activeTabItem?.removeEventListener('pointermove', this._moveAction); this._activeTabItem?.removeEventListener('pointerup', this._upAction); - this.removeListener(); this._config.onChangeTab(downEvent, slideItemId); return; } @@ -571,9 +570,9 @@ export class SlideTabBar { * @param currentIndex */ update(currentIndex: number) { + this.removeListener(); this._config.currentIndex = currentIndex; this._initConfig(); - this.removeListener(); this.addListener(); this.scrollToItem(currentIndex); } diff --git a/packages/sheets-ui/src/views/sheet-container/SheetContainer.tsx b/packages/sheets-ui/src/views/sheet-container/SheetContainer.tsx index 024ca343077f..5399fc7eff80 100644 --- a/packages/sheets-ui/src/views/sheet-container/SheetContainer.tsx +++ b/packages/sheets-ui/src/views/sheet-container/SheetContainer.tsx @@ -14,16 +14,18 @@ * limitations under the License. */ -import type { Workbook } from '@univerjs/core'; +import type { Workbook, Worksheet } from '@univerjs/core'; import type { IUniverSheetsUIConfig } from '../../config/config'; -import { IUniverInstanceService, UniverInstanceType } from '@univerjs/core'; +import { Injector, isInternalEditorID, IUniverInstanceService, UniverInstanceType } from '@univerjs/core'; import { ComponentManager, ContextMenuPosition, IMenuManagerService, ToolbarItem, useConfigValue, useDependency, useObservable } from '@univerjs/ui'; -import { useMemo } from 'react'; +import { useEffect, useMemo } from 'react'; import { SHEETS_UI_PLUGIN_CONFIG_KEY } from '../../config/config'; +import { getEmbedSheetsTabCustomData } from '../../embed-tab-anchor'; +import { ISheetEmbedRuntimeService } from '../../services/sheet-embed-runtime.service'; import { AutoFillPopupMenu } from '../auto-fill-popup-menu/AutoFillPopupMenu'; import { EditorContainer } from '../editor-container/EditorContainer'; import { FormulaBar } from '../formula-bar/FormulaBar'; -import { useActiveWorkbook } from '../hook'; +import { useActiveWorkbook, useActiveWorksheet } from '../hook'; import { SheetBar } from '../sheet-bar/SheetBar'; import { SheetZoomSlider } from '../sheet-slider/CountBar'; import { StatusBar } from '../status-bar/StatusBar'; @@ -35,11 +37,24 @@ export function RenderSheetFooter() { const menuManagerService = useDependency(IMenuManagerService); const showFooter = config?.footer ?? true; const workbook = useActiveWorkbook(); + const activeWorkbookEmbeddedRender = useActiveWorkbookIsEmbeddedRender(workbook); + const focusedUnitType = useFocusedUnitType(); + const activeEmbedTab = useActiveSheetEmbedTabData(workbook); if (!workbook || !showFooter) return null; + if (activeWorkbookEmbeddedRender) return null; + if (!activeEmbedTab && focusedUnitType != null && focusedUnitType !== UniverInstanceType.UNIVER_SHEET) return null; const footerMenus = menuManagerService.getMenuByPositionKey(ContextMenuPosition.FOOTER_MENU); - const { sheetBar = true, statisticBar = true, menus = true, zoomSlider = true } = config?.footer || {}; - if (!sheetBar && !statisticBar && !menus && !zoomSlider) return null; + const { + sheetBar = true, + statisticBar = true, + menus = true, + zoomSlider = true, + } = config?.footer || {}; + const showStatisticBar = activeEmbedTab ? false : statisticBar; + const showMenus = activeEmbedTab ? false : menus; + const showZoomSlider = activeEmbedTab ? false : zoomSlider; + if (!sheetBar && !showStatisticBar && !showMenus && !showZoomSlider) return null; return (
{sheetBar && } - {statisticBar && } - {menus && footerMenus.length > 0 && ( + {showStatisticBar && } + {showMenus && footerMenus.length > 0 && (
{footerMenus.map((item) => item.children?.map((child) => ( child?.item && ( @@ -67,16 +82,33 @@ export function RenderSheetFooter() { )))}
)} - {zoomSlider && } + {showZoomSlider && }
); } export function RenderSheetHeader() { const config = useConfigValue(SHEETS_UI_PLUGIN_CONFIG_KEY); - const hasWorkbook = useHasWorkbook(); + const workbook = useActiveWorkbook(); + const hasWorkbook = !!workbook; + const activeWorkbookEmbeddedRender = useActiveWorkbookIsEmbeddedRender(workbook); + const focusedUnitType = useFocusedUnitType(); + const activeEmbedTab = useActiveSheetEmbedTabData(workbook); if (!hasWorkbook) return null; - + if (activeWorkbookEmbeddedRender) return null; + if (activeEmbedTab) return null; + if (focusedUnitType != null && focusedUnitType !== UniverInstanceType.UNIVER_SHEET) { + return ( +
+ ); + } if (config?.formulaBar !== false) { return ; } @@ -91,12 +123,30 @@ export function RenderSheetContent() { const config = useConfigValue(SHEETS_UI_PLUGIN_CONFIG_KEY); const hasWorkbook = useHasWorkbook(); const componentManager = useDependency(ComponentManager); + const workbook = useActiveWorkbook(); + const activeEmbedTab = useActiveSheetEmbedTabData(workbook); + const injector = useDependency(Injector); + const activeWorkbookEmbeddedRender = useActiveWorkbookIsEmbeddedRender(workbook); + + // We use string keys to avoid a hard dependency on sheets-shape-ui. + const ShapeTextEditorContainer = componentManager.get('SheetShapeTextEditorContainer') ?? componentManager.get('ShapeTextEditorContainer'); - // Attempt to retrieve the registered ShapeTextEditorContainer - // We use a string key to avoid hard dependency on sheets-shape-ui - const ShapeTextEditorContainer = componentManager.get('ShapeTextEditorContainer'); + useEffect(() => { + if (!workbook || activeEmbedTab || activeWorkbookEmbeddedRender) { + return; + } + + const instanceService = injector.get(IUniverInstanceService); + instanceService.setCurrentUnitForType(workbook.getUnitId()); + instanceService.focusUnit(workbook.getUnitId()); + tryGetSheetEmbedRuntimeService(injector)?.clearTab(); + }, [activeEmbedTab, activeWorkbookEmbeddedRender, injector, workbook]); if (!hasWorkbook) return null; + if (activeWorkbookEmbeddedRender) return null; + if (activeEmbedTab && workbook) { + return ; + } return ( <> @@ -107,13 +157,93 @@ export function RenderSheetContent() { ); } +function RenderSheetEmbedTabHost(props: { workbook: Workbook; worksheet: Worksheet }) { + const { workbook, worksheet } = props; + const injector = useDependency(Injector); + const embedData = getEmbedSheetsTabCustomData(worksheet.getConfig()); + const hostUnitId = workbook.getUnitId(); + const hostAnchorId = embedData?.hostAnchorId; + const embedId = embedData?.embedId; + + useEffect(() => { + if (!embedId || !hostAnchorId) { + return undefined; + } + + const embedRuntimeService = tryGetSheetEmbedRuntimeService(injector); + if (!embedRuntimeService) { + return undefined; + } + + try { + const disposable = embedRuntimeService.mountSheetTab({ + hostUnitId, + hostAnchorId, + embedId, + }); + + return () => { + disposable?.dispose(); + }; + } catch (error) { + console.warn('[sheets-ui] failed to mount embedded sheet-tab block', error); + } + + return undefined; + }, [embedId, hostAnchorId, hostUnitId, injector]); + + return ( +
+ ); +} + function useHasWorkbook(): boolean { const univerInstanceService = useDependency(IUniverInstanceService); const workbook = useObservable(() => univerInstanceService.getCurrentTypeOfUnit$(UniverInstanceType.UNIVER_SHEET), null, false, []); - const hasWorkbook = !!workbook; - return useMemo( - () => univerInstanceService.getAllUnitsForType(UniverInstanceType.UNIVER_SHEET).length > 0, + return useMemo(() => !!workbook, [workbook]); +} - [univerInstanceService, hasWorkbook] - ); +function useActiveWorkbookIsEmbeddedRender(workbook: Workbook | null): boolean { + const univerInstanceService = useDependency(IUniverInstanceService); + return useMemo(() => { + if (!workbook) { + return false; + } + + return univerInstanceService.getUnitCreateOptions(workbook.getUnitId())?.embeddedRender === true; + }, [univerInstanceService, workbook]); +} + +function useFocusedUnitType(): UniverInstanceType | null { + const univerInstanceService = useDependency(IUniverInstanceService); + const focusedUnitId = useObservable(() => univerInstanceService.focused$, null, false, [univerInstanceService]); + return useMemo(() => { + if (!focusedUnitId) return null; + + if (isInternalEditorID(focusedUnitId) && univerInstanceService.getCurrentUnitOfType(UniverInstanceType.UNIVER_SHEET)) { + return UniverInstanceType.UNIVER_SHEET; + } + + const focusedUnit = univerInstanceService.getUnit(focusedUnitId); + return focusedUnit?.type ?? null; + }, [focusedUnitId, univerInstanceService]); +} + +function useActiveSheetEmbedTabData(workbook: Workbook | null): { worksheet: Worksheet } | undefined { + const worksheet = useActiveWorksheet(workbook) as Worksheet | null | undefined; + return worksheet && getEmbedSheetsTabCustomData(worksheet.getConfig()) ? { worksheet } : undefined; +} + +function tryGetSheetEmbedRuntimeService(injector: Injector) { + try { + return injector.get(ISheetEmbedRuntimeService); + } catch { + return undefined; + } } diff --git a/packages/sheets/src/facade/__tests__/f-univer.spec.ts b/packages/sheets/src/facade/__tests__/f-univer.spec.ts index 3fa648f02e0a..25d42fb77a22 100644 --- a/packages/sheets/src/facade/__tests__/f-univer.spec.ts +++ b/packages/sheets/src/facade/__tests__/f-univer.spec.ts @@ -16,7 +16,7 @@ import type { Injector } from '@univerjs/core'; import type { FUniver } from '@univerjs/core/facade'; -import { ICommandService } from '@univerjs/core'; +import { ICommandService, UniverInstanceType } from '@univerjs/core'; import { InsertSheetCommand, InsertSheetMutation, @@ -114,6 +114,13 @@ describe('Test FUniver sheets facade', () => { expect(disposed).toEqual([{ unitId: 'facade-workbook', sheetCount: 1 }]); }); + it('keeps embed unit loading outside the sheets facade surface', () => { + type LoadedWorkbook = Awaited>; + const assertWorkbook: LoadedWorkbook extends unknown ? true : false = true; + + expect(assertWorkbook).toBe(true); + }); + it('reports sheet lifecycle events with the resulting workbook state', () => { const workbook = univerAPI.getActiveWorkbook()!; const initialSheet = workbook.getActiveSheet(); @@ -297,3 +304,9 @@ describe('Test FUniver sheets facade', () => { expect(opsSheet.isSheetHidden()).toBe(true); }); }); + +function _loadWorkbookForTypeInference(api: FUniver & { + loadUnitAsync: (ref: string | object, options: { unitType: UniverInstanceType }) => unknown; +}, ref: string | object) { + return api.loadUnitAsync(ref, { unitType: UniverInstanceType.UNIVER_SHEET }); +} diff --git a/packages/sheets/src/facade/f-types.ts b/packages/sheets/src/facade/f-types.ts new file mode 100644 index 000000000000..39b044acfef8 --- /dev/null +++ b/packages/sheets/src/facade/f-types.ts @@ -0,0 +1,17 @@ +/** + * 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. + */ + +export type FSheetEmbedUnitFacadeMapAugmentation = never; diff --git a/packages/sheets/src/facade/index.ts b/packages/sheets/src/facade/index.ts index d8d23ab04c6f..33ecd5247a4a 100644 --- a/packages/sheets/src/facade/index.ts +++ b/packages/sheets/src/facade/index.ts @@ -21,9 +21,9 @@ export * from './f-enum'; export * from './f-event'; export { FRange } from './f-range'; export { FSelection } from './f-selection'; +export type { FSheetEmbedUnitFacadeMapAugmentation } from './f-types'; +export type * from './f-univer'; export { FWorkbook } from './f-workbook'; export { FWorksheet } from './f-worksheet'; -export { FWorkbookPermission } from './permission/f-workbook-permission'; -// eslint-disable-next-line perfectionist/sort-exports -export type * from './f-univer'; +export { FWorkbookPermission } from './permission/f-workbook-permission'; diff --git a/packages/sheets/src/services/permission/range-permission/__tests__/range-protection.service.spec.ts b/packages/sheets/src/services/permission/range-permission/__tests__/range-protection.service.spec.ts index 6f4d0e2f4568..ab077f4b0e1c 100644 --- a/packages/sheets/src/services/permission/range-permission/__tests__/range-protection.service.spec.ts +++ b/packages/sheets/src/services/permission/range-permission/__tests__/range-protection.service.spec.ts @@ -16,8 +16,8 @@ import type { IDisposable, IPermissionPoint, IRange, Workbook } from '@univerjs/core'; import type { IRangeProtectionRule } from '../../../../models/range-protection-rule.model'; -import { Injector, IPermissionService, IResourceManagerService, IUniverInstanceService, PermissionStatus } from '@univerjs/core'; -import { UnitAction, UnitObject } from '@univerjs/protocol'; +import { Injector, IPermissionService, IResourceManagerService, IUniverInstanceService } from '@univerjs/core'; +import { UnitObject } from '@univerjs/protocol'; import { of, Subject } from 'rxjs'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { EditStateEnum, RangeProtectionRuleModel, ViewStateEnum } from '../../../../models/range-protection-rule.model'; @@ -28,8 +28,14 @@ class TestPermissionService { readonly permissionPointUpdate$ = new Subject(); readonly addedPermissionIds: string[] = []; readonly deletedPermissionIds: string[] = []; - - addPermissionPoint(point: { id: string }) { + readonly updatedPermissionPoints: Array<{ id: string; value: unknown }> = []; + readonly permissionPoints = new Map>(); + + addPermissionPoint(point: IPermissionPoint) { + if (this.permissionPoints.has(point.id)) { + return false; + } + this.permissionPoints.set(point.id, point); this.addedPermissionIds.push(point.id); return true; } @@ -38,12 +44,17 @@ class TestPermissionService { this.deletedPermissionIds.push(id); } - updatePermissionPoint() { + updatePermissionPoint(permissionId: string, value: boolean) { + const point = this.permissionPoints.get(permissionId); + if (point) { + point.value = value; + } + this.updatedPermissionPoints.push({ id: permissionId, value }); return true; } - getPermissionPoint(permissionId: string): IPermissionPoint { - return { id: permissionId, value: true, type: UnitObject.SelectRange, subType: UnitAction.Edit, status: PermissionStatus.DONE }; + getPermissionPoint(permissionId: string): IPermissionPoint | undefined { + return this.permissionPoints.get(permissionId); } getPermissionPoint$(permissionId: string) { @@ -53,6 +64,8 @@ class TestPermissionService { clearPermissionMap() { this.addedPermissionIds.length = 0; this.deletedPermissionIds.length = 0; + this.updatedPermissionPoints.length = 0; + this.permissionPoints.clear(); } composePermission(permissionIds: string[]) { @@ -177,4 +190,16 @@ describe('RangeProtectionService', () => { resourceManagerService.registeredResource.onUnLoad('book-2'); expect(cache.deleteUnit).toHaveBeenCalledWith('book-2'); }); + + it('updates existing range permission points when snapshot resources load', () => { + const rule = createRule('rule-1', 'perm-existing'); + ruleModel.addRule('book-1', 'sheet-1', rule); + const existingAddCount = permissionService.addedPermissionIds.length; + + resourceManagerService.registeredResource.onLoad('book-1', { 'sheet-1': [rule] }); + + expect(permissionService.addedPermissionIds.length).toBe(existingAddCount); + expect(permissionService.updatedPermissionPoints.length).toBeGreaterThan(0); + expect(permissionService.updatedPermissionPoints.every((item) => item.id.includes('perm-existing') && item.value === false)).toBe(true); + }); }); diff --git a/packages/sheets/src/services/permission/range-permission/range-protection.service.ts b/packages/sheets/src/services/permission/range-permission/range-protection.service.ts index 93627d3d8417..7ea0cee76a37 100644 --- a/packages/sheets/src/services/permission/range-permission/range-protection.service.ts +++ b/packages/sheets/src/services/permission/range-permission/range-protection.service.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import type { IPermissionPoint } from '@univerjs/core'; import type { UnitAction } from '@univerjs/protocol'; import type { IObjectModel } from '../../../models/range-protection-rule.model'; @@ -121,7 +122,7 @@ export class RangeProtectionService extends Disposable { getAllRangePermissionPoint().forEach((Factor) => { const instance = new Factor(unitId, subUnitId, rule.permissionId); instance.value = false; - this._permissionService.addPermissionPoint(instance); + this._addOrUpdatePermissionPoint(instance); }); }); this._selectionProtectionCache.reBuildCache(unitId, subUnitId); @@ -133,4 +134,13 @@ export class RangeProtectionService extends Disposable { }) ); } + + private _addOrUpdatePermissionPoint(instance: IPermissionPoint) { + if (this._permissionService.getPermissionPoint(instance.id)) { + this._permissionService.updatePermissionPoint(instance.id, instance.value); + return; + } + + this._permissionService.addPermissionPoint(instance); + } } diff --git a/packages/sheets/src/services/permission/worksheet-permission/__tests__/worksheet-permission.service.spec.ts b/packages/sheets/src/services/permission/worksheet-permission/__tests__/worksheet-permission.service.spec.ts index f19f73b86dba..5480dd6e09f0 100644 --- a/packages/sheets/src/services/permission/worksheet-permission/__tests__/worksheet-permission.service.spec.ts +++ b/packages/sheets/src/services/permission/worksheet-permission/__tests__/worksheet-permission.service.spec.ts @@ -45,8 +45,13 @@ class TestPermissionService { readonly addedPermissionIds: string[] = []; readonly deletedPermissionIds: string[] = []; readonly updatedPermissionPoints: Array<{ id: string; value: unknown }> = []; + readonly permissionPoints = new Map(); - addPermissionPoint(point: { id: string }) { + addPermissionPoint(point: { id: string; value?: unknown }) { + if (this.permissionPoints.has(point.id)) { + return false; + } + this.permissionPoints.set(point.id, point); this.addedPermissionIds.push(point.id); return true; } @@ -56,9 +61,17 @@ class TestPermissionService { } updatePermissionPoint(id: string, value: unknown) { + const point = this.permissionPoints.get(id); + if (point) { + point.value = value; + } this.updatedPermissionPoints.push({ id, value }); return true; } + + getPermissionPoint(id: string) { + return this.permissionPoints.get(id); + } } class TestResourceManagerService { @@ -223,4 +236,24 @@ describe('WorksheetPermissionService', () => { expect(worksheetPointModel.getRule('book-1', 'sheet-2')).toBeUndefined(); expect(permissionService.deletedPermissionIds.some((id) => id.includes('book-1') && id.includes('sheet-1'))).toBe(true); }); + + it('updates existing worksheet permission points when rule resources load', () => { + const ruleResource = resourceManagerService.resources[0]; + const existingAddCount = permissionService.addedPermissionIds.length; + + ruleResource.onLoad('book-1', { + 'sheet-1': [{ + permissionId: 'worksheet-perm-1', + unitType: UnitObject.Worksheet, + unitId: 'book-1', + subUnitId: 'sheet-1', + viewState: ViewStateEnum.OthersCanView, + editState: EditStateEnum.OnlyMe, + }], + }); + + expect(permissionService.addedPermissionIds.length).toBe(existingAddCount); + expect(permissionService.updatedPermissionPoints.length).toBeGreaterThan(0); + expect(permissionService.updatedPermissionPoints.every((item) => item.id.includes('book-1') && item.id.includes('sheet-1') && item.value === false)).toBe(true); + }); }); diff --git a/packages/sheets/src/services/permission/worksheet-permission/worksheet-permission.service.ts b/packages/sheets/src/services/permission/worksheet-permission/worksheet-permission.service.ts index 677944242e05..ea6f320cbe11 100644 --- a/packages/sheets/src/services/permission/worksheet-permission/worksheet-permission.service.ts +++ b/packages/sheets/src/services/permission/worksheet-permission/worksheet-permission.service.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import type { Workbook, Worksheet } from '@univerjs/core'; +import type { IPermissionPoint, Workbook, Worksheet } from '@univerjs/core'; import type { IObjectModel, IObjectPointModel } from '../type'; import { ILogService, Inject, Injector, IPermissionService, IResourceManagerService, IUniverInstanceService, RxDisposable, UniverInstanceType } from '@univerjs/core'; @@ -151,7 +151,7 @@ export class WorksheetPermissionService extends RxDisposable { getAllWorksheetPermissionPoint().forEach((F) => { const instance = new F(unitId, subUnitId); instance.value = false; - this._permissionService.addPermissionPoint(instance); + this._addOrUpdatePermissionPoint(instance); }); }); this._worksheetProtectionRuleModel.changeRuleInitState(true); @@ -205,7 +205,7 @@ export class WorksheetPermissionService extends RxDisposable { Object.keys(resources).forEach((subUnitId) => { getAllWorksheetPermissionPointByPointPanel().forEach((F) => { const instance = new F(unitId, subUnitId); - this._permissionService.addPermissionPoint(instance); + this._addOrUpdatePermissionPoint(instance); }); }); }, @@ -215,4 +215,13 @@ export class WorksheetPermissionService extends RxDisposable { }) ); } + + private _addOrUpdatePermissionPoint(instance: IPermissionPoint) { + if (this._permissionService.getPermissionPoint(instance.id)) { + this._permissionService.updatePermissionPoint(instance.id, instance.value); + return; + } + + this._permissionService.addPermissionPoint(instance); + } } diff --git a/packages/ui/src/controllers/ui/__tests__/ui-shared.controller.spec.ts b/packages/ui/src/controllers/ui/__tests__/ui-shared.controller.spec.ts index 6534fc3c4c83..c4ff0e3458da 100644 --- a/packages/ui/src/controllers/ui/__tests__/ui-shared.controller.spec.ts +++ b/packages/ui/src/controllers/ui/__tests__/ui-shared.controller.spec.ts @@ -96,6 +96,7 @@ describe('SingleUnitUIController', () => { const instanceService = { focused$, getFocusedUnit: vi.fn(() => ({ getUnitId: () => 'render-3' })), + getUnitCreateOptions: vi.fn(() => null), }; const lifecycleService = { @@ -147,6 +148,67 @@ describe('SingleUnitUIController', () => { expect(clearTimeoutSpy).toHaveBeenCalled(); }); + it('should not mount embedded renderers into the global workbench content', async () => { + vi.useFakeTimers(); + + const layoutService = { + registerRootContainerElement: vi.fn(() => ({ dispose: vi.fn() })), + registerContentElement: vi.fn(() => ({ dispose: vi.fn() })), + }; + + const focused$ = new Subject(); + const created$ = new Subject(); + const normalRender = createRenderer('normal-render'); + const embeddedRender = createRenderer('embedded-render'); + const rendererMap = new Map([ + ['normal-render', normalRender], + ['embedded-render', embeddedRender], + ]); + + const renderManagerService = { + getRenderAll: vi.fn(() => rendererMap), + getRenderById: vi.fn((id: string) => rendererMap.get(id)), + created$, + disposed$: new Subject(), + }; + + const instanceService = { + focused$, + getFocusedUnit: vi.fn(() => ({ getUnitId: () => 'embedded-render' })), + getUnitCreateOptions: vi.fn((unitId: string) => unitId === 'embedded-render' ? { embeddedRender: true } : null), + }; + + const lifecycleService = { + onStage: vi.fn().mockResolvedValue(undefined), + stage: LifecycleStages.Starting, + }; + + const controller = new TestSingleUnitUIController( + {} as any, + instanceService, + layoutService, + lifecycleService, + renderManagerService, + document.createElement('div'), + document.createElement('div') + ); + + controller.runBootstrap(); + + await controller.callbackPromise; + vi.advanceTimersByTime(300); + + expect(normalRender.engine.mount).toHaveBeenCalledTimes(1); + expect(embeddedRender.engine.mount).not.toHaveBeenCalled(); + + focused$.next('embedded-render'); + expect(normalRender.engine.unmount).not.toHaveBeenCalled(); + expect(embeddedRender.engine.mount).not.toHaveBeenCalled(); + + created$.next({ unitId: 'embedded-render' }); + expect(embeddedRender.engine.mount).not.toHaveBeenCalled(); + }); + it('should ignore LifecycleUnreachableError during bootstrap callback', async () => { const layoutService = { registerRootContainerElement: vi.fn(() => ({ dispose: vi.fn() })), @@ -158,6 +220,7 @@ describe('SingleUnitUIController', () => { { focused$: new Subject(), getFocusedUnit: vi.fn(() => null), + getUnitCreateOptions: vi.fn(() => null), }, layoutService, { diff --git a/packages/ui/src/controllers/ui/ui-shared.controller.ts b/packages/ui/src/controllers/ui/ui-shared.controller.ts index b6d933c09c40..dd2e9a6db55e 100644 --- a/packages/ui/src/controllers/ui/ui-shared.controller.ts +++ b/packages/ui/src/controllers/ui/ui-shared.controller.ts @@ -103,6 +103,7 @@ export abstract class SingleUnitUIController extends Disposable { private _currentRenderId: string | null = null; private _changeRenderUnit(rendererId: string, contentElement: HTMLElement): boolean { if (this._currentRenderId === rendererId) return false; + if (this._instanceService.getUnitCreateOptions(rendererId)?.embeddedRender) return false; const renderer = this._renderManagerService.getRenderById(rendererId)!; if (!renderer || !renderer.unitId || isInternalEditorID(renderer.unitId)) return false; diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index fe3731387911..c9659ee7a8da 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -65,8 +65,8 @@ export { ContextMenuService, IContextMenuService } from './services/contextmenu/ export type { IContextMenuHandler } from './services/contextmenu/contextmenu.service'; export { DesktopDialogService } from './services/dialog/desktop-dialog.service'; export { IDialogService } from './services/dialog/dialog.service'; -export { CanvasFloatDomService } from './services/dom/canvas-dom-layer.service'; -export type { IFloatDom, IFloatDomLayout } from './services/dom/canvas-dom-layer.service'; +export { CanvasFloatDomPreviewService, CanvasFloatDomService } from './services/dom/canvas-dom-layer.service'; +export type { ICanvasFloatDomPreview, ICanvasFloatDomPreviewRequest, IFloatDom, IFloatDomLayout } from './services/dom/canvas-dom-layer.service'; export { FontService, IFontService } from './services/font.service'; export type { IFontConfig } from './services/font.service'; export { DesktopGalleryService } from './services/gallery/desktop-gallery.service'; @@ -113,7 +113,11 @@ export { BuiltInUIPart, IUIPartsService, UIPartsService } from './services/parts export { IPlatformService, PlatformService } from './services/platform/platform.service'; export { CanvasPopupService, ICanvasPopupService } from './services/popup/canvas-popup.service'; export type { IPopup } from './services/popup/canvas-popup.service'; +export { IRibbonOverrideService, RibbonOverrideService } from './services/ribbon/ribbon-override.service'; +export type { IRibbonOverride } from './services/ribbon/ribbon-override.service'; export { DesktopRibbonService, IRibbonService } from './services/ribbon/ribbon.service'; +export { IUIRuntimeScopeService, UIRuntimeScopeService } from './services/runtime-scope/ui-runtime-scope.service'; +export type { IUIRuntimeScope } from './services/runtime-scope/ui-runtime-scope.service'; export { KeyCode, MetaKeys } from './services/shortcut/keycode'; export { ShortcutPanelService } from './services/shortcut/shortcut-panel.service'; export { IShortcutService, ShortcutService } from './services/shortcut/shortcut.service'; diff --git a/packages/ui/src/mobile-plugin.ts b/packages/ui/src/mobile-plugin.ts index 57bb61397c27..7fcf80399f8f 100644 --- a/packages/ui/src/mobile-plugin.ts +++ b/packages/ui/src/mobile-plugin.ts @@ -35,7 +35,7 @@ import { ContextMenuHostService, IContextMenuHostService } from './services/cont import { ContextMenuService, IContextMenuService } from './services/contextmenu/contextmenu.service'; import { DesktopDialogService } from './services/dialog/desktop-dialog.service'; import { IDialogService } from './services/dialog/dialog.service'; -import { CanvasFloatDomService } from './services/dom/canvas-dom-layer.service'; +import { CanvasFloatDomPreviewService, CanvasFloatDomService } from './services/dom/canvas-dom-layer.service'; import { FontService, IFontService } from './services/font.service'; import { DesktopGalleryService } from './services/gallery/desktop-gallery.service'; import { IGalleryService } from './services/gallery/gallery.service'; @@ -52,6 +52,7 @@ import { IUIPartsService, UIPartsService } from './services/parts/parts.service' import { IPlatformService, PlatformService } from './services/platform/platform.service'; import { CanvasPopupService, ICanvasPopupService } from './services/popup/canvas-popup.service'; import { DesktopRibbonService, IRibbonService } from './services/ribbon/ribbon.service'; +import { IUIRuntimeScopeService, UIRuntimeScopeService } from './services/runtime-scope/ui-runtime-scope.service'; import { ShortcutPanelService } from './services/shortcut/shortcut-panel.service'; import { IShortcutService, ShortcutService } from './services/shortcut/shortcut.service'; import { DesktopSidebarService } from './services/sidebar/desktop-sidebar.service'; @@ -111,6 +112,7 @@ export class UniverMobileUIPlugin extends Plugin { [IMenuManagerService, { useClass: MenuManagerService }], [IContextMenuHostService, { useClass: ContextMenuHostService }], [IContextMenuService, { useClass: ContextMenuService }], + [IUIRuntimeScopeService, { useClass: UIRuntimeScopeService }], [IClipboardInterfaceService, { useClass: BrowserClipboardService, lazy: true }], [INotificationService, { useClass: DesktopNotificationService, lazy: true }], [IGalleryService, { useClass: DesktopGalleryService, lazy: true }], @@ -124,6 +126,7 @@ export class UniverMobileUIPlugin extends Plugin { [ICanvasPopupService, { useClass: CanvasPopupService }], [IFontService, { useClass: FontService }], [CanvasFloatDomService], + [CanvasFloatDomPreviewService], [ IUIController, diff --git a/packages/ui/src/plugin.ts b/packages/ui/src/plugin.ts index ef0e6ba255b7..4e6928ce0473 100644 --- a/packages/ui/src/plugin.ts +++ b/packages/ui/src/plugin.ts @@ -35,7 +35,7 @@ import { ContextMenuHostService, IContextMenuHostService } from './services/cont import { ContextMenuService, IContextMenuService } from './services/contextmenu/contextmenu.service'; import { DesktopDialogService } from './services/dialog/desktop-dialog.service'; import { IDialogService } from './services/dialog/dialog.service'; -import { CanvasFloatDomService } from './services/dom/canvas-dom-layer.service'; +import { CanvasFloatDomPreviewService, CanvasFloatDomService } from './services/dom/canvas-dom-layer.service'; import { FontService, IFontService } from './services/font.service'; import { DesktopGalleryService } from './services/gallery/desktop-gallery.service'; import { IGalleryService } from './services/gallery/gallery.service'; @@ -51,7 +51,9 @@ import { INotificationService } from './services/notification/notification.servi import { IUIPartsService, UIPartsService } from './services/parts/parts.service'; import { IPlatformService, PlatformService } from './services/platform/platform.service'; import { CanvasPopupService, ICanvasPopupService } from './services/popup/canvas-popup.service'; +import { IRibbonOverrideService, RibbonOverrideService } from './services/ribbon/ribbon-override.service'; import { DesktopRibbonService, IRibbonService } from './services/ribbon/ribbon.service'; +import { IUIRuntimeScopeService, UIRuntimeScopeService } from './services/runtime-scope/ui-runtime-scope.service'; import { ShortcutPanelService } from './services/shortcut/shortcut-panel.service'; import { IShortcutService, ShortcutService } from './services/shortcut/shortcut.service'; import { DesktopSidebarService } from './services/sidebar/desktop-sidebar.service'; @@ -106,11 +108,13 @@ export class UniverUIPlugin extends Plugin { [IUIPartsService, { useClass: UIPartsService }], [ILayoutService, { useClass: DesktopLayoutService }], [IRibbonService, { useClass: DesktopRibbonService }], + [IRibbonOverrideService, { useClass: RibbonOverrideService }], [IShortcutService, { useClass: ShortcutService }], [IPlatformService, { useClass: PlatformService }], [IMenuManagerService, { useClass: MenuManagerService }], [IContextMenuHostService, { useClass: ContextMenuHostService }], [IContextMenuService, { useClass: ContextMenuService }], + [IUIRuntimeScopeService, { useClass: UIRuntimeScopeService }], [IClipboardInterfaceService, { useClass: BrowserClipboardService, lazy: true }], [INotificationService, { useClass: DesktopNotificationService, lazy: true }], [IGalleryService, { useClass: DesktopGalleryService, lazy: true }], @@ -124,6 +128,7 @@ export class UniverUIPlugin extends Plugin { [ICanvasPopupService, { useClass: CanvasPopupService }], [IFontService, { useClass: FontService }], [CanvasFloatDomService], + [CanvasFloatDomPreviewService], [IUIController, { useFactory: (injector: Injector) => injector.createInstance(DesktopUIController, this._config), deps: [Injector], diff --git a/packages/ui/src/services/contextmenu/contextmenu.service.ts b/packages/ui/src/services/contextmenu/contextmenu.service.ts index 4f03b92e0cbe..405ea4ba8917 100644 --- a/packages/ui/src/services/contextmenu/contextmenu.service.ts +++ b/packages/ui/src/services/contextmenu/contextmenu.service.ts @@ -18,9 +18,13 @@ import type { IDisposable } from '@univerjs/core'; import type { IMouseEvent, IPointerEvent } from '@univerjs/engine-render'; import { createIdentifier, Disposable, toDisposable } from '@univerjs/core'; +export interface IContextMenuTriggerContext { + unitId?: string; +} + export interface IContextMenuHandler { /** A callback to open context menu with given position and menu type. */ - handleContextMenu(event: IPointerEvent | IMouseEvent, menuType: string): void; + handleContextMenu(event: IPointerEvent | IMouseEvent, menuType: string, context?: IContextMenuTriggerContext): void; hideContextMenu(): void; get visible(): boolean; @@ -32,7 +36,7 @@ export interface IContextMenuService { enable(): void; disable(): void; - triggerContextMenu(event: IPointerEvent | IMouseEvent, menuType: string): void; + triggerContextMenu(event: IPointerEvent | IMouseEvent, menuType: string, context?: IContextMenuTriggerContext): void; hideContextMenu(): void; registerContextMenuHandler(handler: IContextMenuHandler): IDisposable; } @@ -55,11 +59,11 @@ export class ContextMenuService extends Disposable implements IContextMenuServic this.disabled = false; } - triggerContextMenu(event: IPointerEvent | IMouseEvent, menuType: string): void { + triggerContextMenu(event: IPointerEvent | IMouseEvent, menuType: string, context?: IContextMenuTriggerContext): void { event.stopPropagation(); if (this.disabled) return; - this._currentHandler?.handleContextMenu(event, menuType); + this._currentHandler?.handleContextMenu(event, menuType, context); } hideContextMenu(): void { diff --git a/packages/ui/src/services/dom/__tests__/canvas-dom-layer.service.spec.ts b/packages/ui/src/services/dom/__tests__/canvas-dom-layer.service.spec.ts index 9fce3d2d1f27..9d936158fce6 100644 --- a/packages/ui/src/services/dom/__tests__/canvas-dom-layer.service.spec.ts +++ b/packages/ui/src/services/dom/__tests__/canvas-dom-layer.service.spec.ts @@ -18,7 +18,7 @@ import type { IFloatDom, IFloatDomLayout } from '../canvas-dom-layer.service'; import { Injector } from '@univerjs/core'; import { BehaviorSubject } from 'rxjs'; import { describe, expect, it } from 'vitest'; -import { CanvasFloatDomService } from '../canvas-dom-layer.service'; +import { CanvasFloatDomPreviewService, CanvasFloatDomService } from '../canvas-dom-layer.service'; function createService(): CanvasFloatDomService { const injector = new Injector(); @@ -78,4 +78,48 @@ describe('CanvasFloatDomService', () => { expect(sizes).toEqual([0, 1, 2, 0]); sub.unsubscribe(); }); + + it('clears retained layer callbacks when disposed', () => { + const service = createService(); + const sizes: number[] = []; + let completed = false; + service.domLayers$.subscribe({ + next: (layers) => sizes.push(layers.length), + complete: () => { + completed = true; + }, + }); + + service.addFloatDom(createFloatDom('dom-1')); + service.dispose(); + + expect(service.domLayers).toEqual([]); + expect(sizes).toEqual([0, 1, 0]); + expect(completed).toBe(true); + }); + + it('clears preview state when disposed', () => { + const service = new CanvasFloatDomPreviewService(); + let previewCompleted = false; + let requestCompleted = false; + service.previewUpdated$.subscribe({ + complete: () => { + previewCompleted = true; + }, + }); + service.previewRequested$.subscribe({ + complete: () => { + requestCompleted = true; + }, + }); + + service.requestPreview({ id: 'dom-1', width: 20, height: 10 }); + service.setPreview({ id: 'dom-1', image: 'data:image/png;base64,', updatedAt: 1 }); + service.dispose(); + + expect(service.getPreview('dom-1')).toBeUndefined(); + expect(service.getPendingRequests()).toEqual([]); + expect(previewCompleted).toBe(true); + expect(requestCompleted).toBe(true); + }); }); diff --git a/packages/ui/src/services/dom/canvas-dom-layer.service.ts b/packages/ui/src/services/dom/canvas-dom-layer.service.ts index 5816c29390d9..c393dd7042b0 100644 --- a/packages/ui/src/services/dom/canvas-dom-layer.service.ts +++ b/packages/ui/src/services/dom/canvas-dom-layer.service.ts @@ -16,7 +16,8 @@ import type { IPosition, Serializable } from '@univerjs/core'; import type { Observable } from 'rxjs'; -import { BehaviorSubject } from 'rxjs'; +import { Disposable } from '@univerjs/core'; +import { BehaviorSubject, Subject } from 'rxjs'; export interface IFloatDomLayout extends IPosition { rotate: number; @@ -34,6 +35,18 @@ export interface IFloatDom { id: string; domId?: string; // Ensure unique id for dom element at runtime componentKey: string | React.ComponentType; + /** + * Whether pointer and wheel events inside the floating DOM should be + * forwarded back to the host canvas. Existing canvas-owned float DOMs keep + * forwarding by default; interactive embed runtimes can opt out. + */ + eventPassThrough?: boolean; + /** + * Keep rendering this host-owned layer even when focus temporarily moves + * into a child runtime unit. Interactive embed float blocks need this so + * their DOM portal is not filtered out when the child handles focus. + */ + preserveOnFocusChange?: boolean; onPointerMove: (evt: PointerEvent | MouseEvent) => void; onPointerDown: (evt: PointerEvent | MouseEvent) => void; onPointerUp: (evt: PointerEvent | MouseEvent) => void; @@ -43,7 +56,15 @@ export interface IFloatDom { unitId: string; } -export class CanvasFloatDomService { +export function shouldForwardFloatDomEvents(layer: Pick): boolean { + return layer.eventPassThrough !== false; +} + +export function shouldRenderFloatDomLayer(layer: Pick, currentUnitId: string | null | undefined): boolean { + return layer.unitId === currentUnitId || layer.preserveOnFocusChange === true; +} + +export class CanvasFloatDomService extends Disposable { private _domLayerMap = new Map(); private _domLayers$ = new BehaviorSubject<[string, IFloatDom][]>([]); @@ -85,4 +106,64 @@ export class CanvasFloatDomService { this._domLayerMap.clear(); this._notice(); } + + override dispose(): void { + this._domLayerMap.clear(); + this._domLayers$.next([]); + this._domLayers$.complete(); + super.dispose(); + } +} + +export interface ICanvasFloatDomPreview { + id: string; + image: string; + updatedAt: number; +} + +export interface ICanvasFloatDomPreviewRequest { + id: string; + width: number; + height: number; + data?: unknown; +} + +export class CanvasFloatDomPreviewService extends Disposable { + readonly previewUpdated$ = new Subject(); + readonly previewRequested$ = new Subject(); + + private readonly _previewMap = new Map(); + private readonly _requestMap = new Map(); + + getPreview(id: string): ICanvasFloatDomPreview | undefined { + return this._previewMap.get(id); + } + + getPendingRequests(): ICanvasFloatDomPreviewRequest[] { + return Array.from(this._requestMap.values()); + } + + setPreview(preview: ICanvasFloatDomPreview): void { + this._previewMap.set(preview.id, preview); + this._requestMap.delete(preview.id); + this.previewUpdated$.next(preview); + } + + removePreview(id: string): void { + this._previewMap.delete(id); + this._requestMap.delete(id); + } + + requestPreview(request: ICanvasFloatDomPreviewRequest): void { + this._requestMap.set(request.id, request); + this.previewRequested$.next(request); + } + + override dispose(): void { + this._previewMap.clear(); + this._requestMap.clear(); + this.previewUpdated$.complete(); + this.previewRequested$.complete(); + super.dispose(); + } } diff --git a/packages/ui/src/services/layout/__tests__/layout.service.spec.ts b/packages/ui/src/services/layout/__tests__/layout.service.spec.ts index 97130b791ec5..b5424592f3d7 100644 --- a/packages/ui/src/services/layout/__tests__/layout.service.spec.ts +++ b/packages/ui/src/services/layout/__tests__/layout.service.spec.ts @@ -35,8 +35,12 @@ class TestSlideUnit extends UnitModel { private readonly _name$ = new BehaviorSubject(''); override name$ = this._name$.asObservable(); + constructor(private readonly _unitId = 'slide-1') { + super(); + } + override getUnitId(): string { - return 'slide-1'; + return this._unitId; } override setName(name: string): void { @@ -100,6 +104,7 @@ function createService() { injector, service: injector.get(ILayoutService), contextService: injector.get(IContextService), + univerInstanceService, }; } @@ -179,8 +184,57 @@ describe('DesktopLayoutService', () => { expect(contextService.getContextValue(FOCUSING_UNIVER)).toBe(true); expect(contextService.getContextValue(FOCUSING_UNIVER_EDITOR)).toBe(false); + contextService.setContextValue(FOCUSING_UNIVER_EDITOR, true); + Object.defineProperty(globalThis.document, 'activeElement', { + configurable: true, + get: () => button, + }); dispatchFocusIn(button); await Promise.resolve(); expect(focused).toEqual(['slide-1']); + expect(service.isFocused).toBe(true); + expect(contextService.getContextValue(FOCUSING_UNIVER)).toBe(true); + expect(contextService.getContextValue(FOCUSING_UNIVER_EDITOR)).toBe(false); + }); + + it('does not give focus back to the host unit when an embed-owned render canvas receives focus', async () => { + const { service } = createService(); + const focused: string[] = []; + const embedCanvas = { + dataset: { uComp: 'render-canvas' }, + closest: vi.fn((selector: string) => selector === '[data-embed-interaction-boundary-owner]' ? {} : null), + } as unknown as HTMLElement; + const root = { + dataset: { uComp: 'app-layout' }, + contains: vi.fn((target: unknown) => target === embedCanvas), + } as unknown as HTMLElement; + + service.registerRootContainerElement(root); + service.registerFocusHandler(UniverInstanceType.UNIVER_SLIDE, (unitId) => focused.push(unitId)); + + dispatchFocusIn(embedCanvas); + await Promise.resolve(); + + expect(focused).toEqual([]); + }); + + it('focuses the unit declared by a render canvas before delegating focus', async () => { + const { injector, service, univerInstanceService } = createService(); + const focused: string[] = []; + const root = createElement('app-layout'); + const canvas = createElement('render-canvas'); + canvas.dataset.uUnitId = 'slide-2'; + root.contains = (target: unknown) => target === canvas; + const slide2 = injector.createInstance(TestSlideUnit, 'slide-2'); + + univerInstanceService.__addUnit(slide2, { makeCurrent: false }); + service.registerRootContainerElement(root); + service.registerFocusHandler(UniverInstanceType.UNIVER_SLIDE, (unitId) => focused.push(unitId)); + + dispatchFocusIn(canvas); + await Promise.resolve(); + + expect(univerInstanceService.getFocusedUnit()?.getUnitId()).toBe('slide-2'); + expect(focused).toEqual(['slide-2']); }); }); diff --git a/packages/ui/src/services/layout/layout.service.ts b/packages/ui/src/services/layout/layout.service.ts index f881a12d7ee5..18ffa05ad95f 100644 --- a/packages/ui/src/services/layout/layout.service.ts +++ b/packages/ui/src/services/layout/layout.service.ts @@ -28,6 +28,7 @@ import { Workbook, } from '@univerjs/core'; import { fromEvent } from 'rxjs'; +import { isEmbedBoundaryTarget } from '../../utils/embed-boundary'; type FocusHandlerFn = (unitId: string) => void; @@ -175,8 +176,22 @@ export class DesktopLayoutService extends Disposable implements ILayoutService { fromEvent(window, 'focusin').subscribe((event) => { const target = event.target as HTMLElement; - if (this._rootContainerElement?.contains(target) && givingBackFocusElements.some((item) => target.dataset.uComp === item)) { - queueMicrotask(() => this.focus()); + if ( + this._rootContainerElement?.contains(target) && + givingBackFocusElements.some((item) => target.dataset.uComp === item) && + !isEmbedBoundaryTarget(target) + ) { + queueMicrotask(() => { + const targetUnitId = getFocusUnitIdFromElement(target); + if (targetUnitId && this._univerInstanceService.getUnit(targetUnitId)) { + this._univerInstanceService.focusUnit(targetUnitId); + } + + this.focus(); + this._isFocused = true; + this._contextService.setContextValue(FOCUSING_UNIVER, this._isFocused); + this._contextService.setContextValue(FOCUSING_UNIVER_EDITOR, getFocusingUniverEditorStatus()); + }); return; } @@ -200,3 +215,7 @@ export class DesktopLayoutService extends Disposable implements ILayoutService { function getFocusingUniverEditorStatus(): boolean { return (document.activeElement as HTMLElement)?.dataset.uComp === 'editor'; } + +function getFocusUnitIdFromElement(target: HTMLElement): string | undefined { + return target.dataset.uUnitId; +} diff --git a/packages/ui/src/services/menu/menu-manager.service.ts b/packages/ui/src/services/menu/menu-manager.service.ts index 700fcea39c0d..37d20048d05d 100644 --- a/packages/ui/src/services/menu/menu-manager.service.ts +++ b/packages/ui/src/services/menu/menu-manager.service.ts @@ -293,6 +293,23 @@ export class MenuManagerService extends Disposable implements IMenuManagerServic this.menuChanged$.next(); } + createScoped(injector: Injector): IMenuManagerService { + const root = this; + const createScopedBuilder = () => { + const service = new MenuManagerService(injector, root._configService); + service._menu = root._menu; + return service; + }; + + return { + menuChanged$: root.menuChanged$, + mergeMenu: (source: MenuSchemaType, target?: MenuSchemaType) => root.mergeMenu(source, target), + appendRootMenu: (source: MenuSchemaType) => root.appendRootMenu(source), + getMenuByPositionKey: (position: string) => createScopedBuilder().getMenuByPositionKey(position), + getFlatMenuByPositionKey: (position: string) => createScopedBuilder().getFlatMenuByPositionKey(position), + }; + } + private _buildMenuSchema(data: MenuSchemaType): IMenuSchema[] { const result: IMenuSchema[] = []; diff --git a/packages/ui/src/services/popup/canvas-popup.service.ts b/packages/ui/src/services/popup/canvas-popup.service.ts index dac580168b44..45995f2030d6 100644 --- a/packages/ui/src/services/popup/canvas-popup.service.ts +++ b/packages/ui/src/services/popup/canvas-popup.service.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import type { Nullable } from '@univerjs/core'; +import type { Injector, Nullable } from '@univerjs/core'; import type { IBoundRectNoAngle } from '@univerjs/engine-render'; import type { Observable } from 'rxjs'; import type { IRectPopupProps } from '../../views/components/popup/RectPopup'; @@ -27,6 +27,7 @@ export interface IPopup> extends Omit; excludeRects?: Nullable; componentKey: string; + connectorInjector?: Injector; unitId: string; subUnitId: string; diff --git a/packages/ui/src/services/ribbon/ribbon-override.service.ts b/packages/ui/src/services/ribbon/ribbon-override.service.ts new file mode 100644 index 000000000000..b368c2638c04 --- /dev/null +++ b/packages/ui/src/services/ribbon/ribbon-override.service.ts @@ -0,0 +1,69 @@ +/** + * 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 type { Injector } from '@univerjs/core'; +import type { Observable } from 'rxjs'; +import type { IRibbonService } from './ribbon.service'; +import { createIdentifier, Disposable } from '@univerjs/core'; +import { BehaviorSubject } from 'rxjs'; + +export interface IRibbonOverride { + id: string; + ribbonService: IRibbonService; + injector?: Pick; + portalContainer?: HTMLElement | null; + placeholderTitle?: string; + hideToolbar?: boolean; +} + +export interface IRibbonOverrideService { + readonly override$: Observable; + getOverride(): IRibbonOverride | null; + activate(override: IRibbonOverride): void; + clear(id?: string): void; +} + +export const IRibbonOverrideService = createIdentifier('univer.ribbon-override-service'); + +export class RibbonOverrideService extends Disposable implements IRibbonOverrideService { + private readonly _override$ = new BehaviorSubject(null); + readonly override$ = this._override$.asObservable(); + + getOverride(): IRibbonOverride | null { + return this._override$.getValue(); + } + + activate(override: IRibbonOverride): void { + this._override$.next(override); + } + + clear(id?: string): void { + const current = this.getOverride(); + if (!current) { + return; + } + + if (!id || current.id === id) { + this._override$.next(null); + } + } + + override dispose(): void { + this._override$.next(null); + this._override$.complete(); + super.dispose(); + } +} diff --git a/packages/ui/src/services/runtime-scope/ui-runtime-scope.service.ts b/packages/ui/src/services/runtime-scope/ui-runtime-scope.service.ts new file mode 100644 index 000000000000..6cbc7d2b37d9 --- /dev/null +++ b/packages/ui/src/services/runtime-scope/ui-runtime-scope.service.ts @@ -0,0 +1,49 @@ +/** + * 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 type { IDisposable } from '@univerjs/core'; +import { createIdentifier, Disposable, toDisposable } from '@univerjs/core'; + +export interface IUIRuntimeScope { + unitId: string; + has(identifier: unknown): boolean; + get(identifier: unknown): T; +} + +export interface IUIRuntimeScopeService { + register(scope: IUIRuntimeScope): IDisposable; + get(unitId: string | null | undefined): IUIRuntimeScope | undefined; +} + +export const IUIRuntimeScopeService = createIdentifier('ui.runtime-scope.service'); + +export class UIRuntimeScopeService extends Disposable implements IUIRuntimeScopeService { + private readonly _scopes = new Map(); + + register(scope: IUIRuntimeScope): IDisposable { + this._scopes.set(scope.unitId, scope); + + return toDisposable(() => { + if (this._scopes.get(scope.unitId) === scope) { + this._scopes.delete(scope.unitId); + } + }); + } + + get(unitId: string | null | undefined): IUIRuntimeScope | undefined { + return unitId ? this._scopes.get(unitId) : undefined; + } +} diff --git a/packages/ui/src/services/shortcut/__tests__/shortcut.service.spec.ts b/packages/ui/src/services/shortcut/__tests__/shortcut.service.spec.ts index 66f672308e9a..c76acd08be84 100644 --- a/packages/ui/src/services/shortcut/__tests__/shortcut.service.spec.ts +++ b/packages/ui/src/services/shortcut/__tests__/shortcut.service.spec.ts @@ -14,6 +14,26 @@ * limitations under the License. */ +/** + * 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. + */ + +/** + * @vitest-environment jsdom + */ + import { ICommandService, IContextService, Injector } from '@univerjs/core'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { ILayoutService } from '../../layout/layout.service'; @@ -251,4 +271,90 @@ describe('ShortcutService', () => { expect(event.defaultPrevented).toBe(true); service.dispose(); }); + + it('lets embed-owned native text editors handle select-all without dispatching Univer shortcuts', () => { + const embedRoot = document.createElement('div'); + embedRoot.setAttribute('data-embed-interaction-boundary-owner', 'embed-1'); + const textEditor = document.createElement('div'); + textEditor.contentEditable = 'true'; + Object.defineProperty(textEditor, 'isContentEditable', { + configurable: true, + get: () => true, + }); + embedRoot.appendChild(textEditor); + const { service, executeCommand } = createService(); + service.registerShortcut({ + id: 'cmd.select-all', + binding: KeyCode.A | MetaKeys.CTRL_COMMAND, + }); + + const event = createKeyboardEvent(KeyCode.A, { ctrlKey: true }); + Object.defineProperty(event, 'target', { + configurable: true, + get: () => textEditor, + }); + const candidate = service.dispatch(event); + + expect(candidate).toBeUndefined(); + expect(executeCommand).not.toHaveBeenCalled(); + expect(event.defaultPrevented).toBe(false); + service.dispose(); + }); + + it('lets embed-owned Univer internal editors handle select-all without dispatching global shortcuts', () => { + const embedRoot = document.createElement('div'); + embedRoot.setAttribute('data-embed-interaction-boundary-owner', 'embed-1'); + const editorContainer = document.createElement('div'); + editorContainer.id = 'univer-doc-selection-container-__INTERNAL_EDITOR__DOCS_NORMAL'; + const textEditor = document.createElement('div'); + textEditor.id = '__editor___INTERNAL_EDITOR__DOCS_NORMAL'; + textEditor.contentEditable = 'true'; + Object.defineProperty(textEditor, 'isContentEditable', { + configurable: true, + get: () => true, + }); + editorContainer.appendChild(textEditor); + embedRoot.appendChild(editorContainer); + const { service, executeCommand } = createService(); + service.registerShortcut({ + id: 'cmd.select-all', + binding: KeyCode.A | MetaKeys.CTRL_COMMAND, + }); + + const event = createKeyboardEvent(KeyCode.A, { ctrlKey: true }); + Object.defineProperty(event, 'target', { + configurable: true, + get: () => textEditor, + }); + const candidate = service.dispatch(event); + + expect(candidate).toBeUndefined(); + expect(executeCommand).not.toHaveBeenCalled(); + expect(event.defaultPrevented).toBe(false); + service.dispose(); + }); + + it('still dispatches select-all for non-text embed targets', () => { + const embedRoot = document.createElement('div'); + embedRoot.setAttribute('data-embed-interaction-boundary-owner', 'embed-1'); + const canvas = document.createElement('canvas'); + embedRoot.appendChild(canvas); + const { service, executeCommand } = createService(); + service.registerShortcut({ + id: 'cmd.select-all', + binding: KeyCode.A | MetaKeys.CTRL_COMMAND, + }); + + const event = createKeyboardEvent(KeyCode.A, { ctrlKey: true }); + Object.defineProperty(event, 'target', { + configurable: true, + get: () => canvas, + }); + const candidate = service.dispatch(event); + + expect(candidate?.id).toBe('cmd.select-all'); + expect(executeCommand).not.toHaveBeenCalled(); + expect(event.defaultPrevented).toBe(false); + service.dispose(); + }); }); diff --git a/packages/ui/src/services/shortcut/shortcut.service.ts b/packages/ui/src/services/shortcut/shortcut.service.ts index 79488fb67d7d..61cc1161668b 100644 --- a/packages/ui/src/services/shortcut/shortcut.service.ts +++ b/packages/ui/src/services/shortcut/shortcut.service.ts @@ -16,13 +16,13 @@ import type { IDisposable } from '@univerjs/core'; import type { Observable } from 'rxjs'; -import type { KeyCode } from './keycode'; import { createIdentifier, Disposable, ICommandService, IContextService, Optional, toDisposable } from '@univerjs/core'; import { Subject } from 'rxjs'; import { fromGlobalEvent } from '../../common/lifecycle'; +import { isEmbedBoundaryTarget } from '../../utils/embed-boundary'; import { ILayoutService } from '../layout/layout.service'; import { IPlatformService } from '../platform/platform.service'; -import { KeyCodeToChar, MetaKeys } from './keycode'; +import { KeyCode, KeyCodeToChar, MetaKeys } from './keycode'; /** * A shortcut item that could be registered to the {@link IShortcutService}. @@ -293,6 +293,10 @@ export class ShortcutService extends Disposable implements IShortcutService { return undefined; } + if (this._shouldLetEmbedTextEditorHandleNativeShortcut(e, binding)) { + return undefined; + } + const shortcuts = this._shortCutMapping.get(binding); if (shortcuts === undefined) { return undefined; @@ -345,4 +349,21 @@ export class ShortcutService extends Disposable implements IShortcutService { return binding; } + + private _shouldLetEmbedTextEditorHandleNativeShortcut(e: KeyboardEvent, binding: number): boolean { + if (binding !== (KeyCode.A | MetaKeys.CTRL_COMMAND)) { + return false; + } + + const target = e.target; + if (!(target instanceof HTMLElement)) { + return false; + } + + const isNativeTextEditor = target.isContentEditable || + target instanceof HTMLInputElement || + target instanceof HTMLTextAreaElement; + + return isNativeTextEditor && isEmbedBoundaryTarget(target); + } } diff --git a/packages/ui/src/utils/__tests__/embed-boundary.spec.ts b/packages/ui/src/utils/__tests__/embed-boundary.spec.ts new file mode 100644 index 000000000000..237c87a2d0e1 --- /dev/null +++ b/packages/ui/src/utils/__tests__/embed-boundary.spec.ts @@ -0,0 +1,80 @@ +/** + * 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. + */ + +/** + * @vitest-environment jsdom + */ + +import { describe, expect, it, vi } from 'vitest'; +import { EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE, getEmbedBoundaryOwner, isEmbedBoundaryTarget, keepInteractionInsideSameEmbedBoundary } from '../embed-boundary'; + +describe('embed boundary utilities', () => { + it('returns undefined for non-element targets so non-embed UI keeps the default behavior', () => { + expect(getEmbedBoundaryOwner(null)).toBeUndefined(); + expect(getEmbedBoundaryOwner(new EventTarget())).toBeUndefined(); + expect(isEmbedBoundaryTarget(new EventTarget())).toBe(false); + }); + + it('resolves the nearest embed boundary owner from a descendant target', () => { + const owner = document.createElement('div'); + const target = document.createElement('button'); + + owner.setAttribute(EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE, 'embed-1'); + owner.appendChild(target); + + expect(getEmbedBoundaryOwner(target)).toBe('embed-1'); + expect(isEmbedBoundaryTarget(target)).toBe(true); + }); + + it('prevents outside handling only when both targets belong to the same embed boundary', () => { + const currentOwner = document.createElement('div'); + const current = document.createElement('button'); + const sameOwner = document.createElement('div'); + const sameTarget = document.createElement('input'); + const otherOwner = document.createElement('div'); + const otherTarget = document.createElement('input'); + const preventDefault = vi.fn(); + + currentOwner.setAttribute(EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE, 'embed-1'); + sameOwner.setAttribute(EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE, 'embed-1'); + otherOwner.setAttribute(EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE, 'embed-2'); + currentOwner.appendChild(current); + sameOwner.appendChild(sameTarget); + otherOwner.appendChild(otherTarget); + + keepInteractionInsideSameEmbedBoundary({ + currentTarget: current, + target: sameTarget, + preventDefault, + }); + + expect(preventDefault).toHaveBeenCalledTimes(1); + + keepInteractionInsideSameEmbedBoundary({ + currentTarget: current, + target: otherTarget, + preventDefault, + }); + + keepInteractionInsideSameEmbedBoundary({ + currentTarget: document.createElement('button'), + target: sameTarget, + preventDefault, + }); + + expect(preventDefault).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/ui/src/utils/embed-boundary.ts b/packages/ui/src/utils/embed-boundary.ts new file mode 100644 index 000000000000..bf3ecc450275 --- /dev/null +++ b/packages/ui/src/utils/embed-boundary.ts @@ -0,0 +1,65 @@ +/** + * 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. + */ + +export const EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE = 'data-embed-interaction-boundary-owner'; + +interface IEmbedBoundaryElementLike { + getAttribute: (name: string) => string | null; + closest: (selector: string) => IEmbedBoundaryElementLike | null; +} + +export function getEmbedBoundaryOwner(target: EventTarget | null): string | undefined { + if (!hasClosest(target)) { + return undefined; + } + + const ownerElement = target.closest(`[${EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE}]`); + + return getAttributeValue(target, EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE) ?? + getAttributeValue(ownerElement, EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE) ?? + undefined; +} + +export function isEmbedBoundaryTarget(target: EventTarget | null): boolean { + return hasClosest(target) && target.closest(`[${EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE}]`) != null; +} + +export function keepInteractionInsideSameEmbedBoundary(event: { + currentTarget: EventTarget | null; + target: EventTarget | null; + preventDefault: () => void; +}): void { + const owner = getEmbedBoundaryOwner(event.currentTarget); + if (!owner) { + return; + } + + if (getEmbedBoundaryOwner(event.target) === owner) { + event.preventDefault(); + } +} + +function hasClosest(target: EventTarget | null): target is EventTarget & Pick { + return !!target && + typeof (target as Partial).closest === 'function'; +} + +function getAttributeValue(target: unknown, name: string): string | undefined { + const getAttribute = (target as Partial | null | undefined)?.getAttribute; + return typeof getAttribute === 'function' + ? getAttribute.call(target, name) ?? undefined + : undefined; +} diff --git a/packages/ui/src/utils/index.ts b/packages/ui/src/utils/index.ts index b9d6ed71e6a1..f36ae0ef6774 100644 --- a/packages/ui/src/utils/index.ts +++ b/packages/ui/src/utils/index.ts @@ -16,5 +16,6 @@ export * from './cell'; export * from './di'; +export * from './embed-boundary'; export * from './html'; export * from './util'; diff --git a/packages/ui/src/views/components/context-menu/AnchoredContextMenu.tsx b/packages/ui/src/views/components/context-menu/AnchoredContextMenu.tsx index 0c0d2ea74cd1..193c40f31df3 100644 --- a/packages/ui/src/views/components/context-menu/AnchoredContextMenu.tsx +++ b/packages/ui/src/views/components/context-menu/AnchoredContextMenu.tsx @@ -14,7 +14,9 @@ * limitations under the License. */ +import type { ILayoutService } from '../../../services/layout/layout.service'; import type { IValueOption } from '../../../services/menu/menu'; +import type { IMenuManagerService } from '../../../services/menu/menu-manager.service'; import { Popup } from '@univerjs/design'; import { useEffect, useLayoutEffect, useMemo, useRef } from 'react'; import { IContextMenuHostService } from '../../../services/contextmenu/contextmenu-host.service'; @@ -34,6 +36,8 @@ export interface IAnchoredContextMenuProps { menuType: string; anchorVertical?: 'top' | 'bottom'; menuOffset?: number; + menuManagerService?: IMenuManagerService; + layoutService?: ILayoutService; onRequestClose: () => void; onOptionSelect?: (option: IValueOption) => void; } @@ -46,6 +50,8 @@ export function AnchoredContextMenu(props: IAnchoredContextMenuProps) { menuType, anchorVertical = 'bottom', menuOffset = 0, + menuManagerService, + layoutService, onRequestClose, onOptionSelect, } = props; @@ -161,6 +167,8 @@ export function AnchoredContextMenu(props: IAnchoredContextMenuProps) { {menuType && ( diff --git a/packages/ui/src/views/components/context-menu/ContextMenu.tsx b/packages/ui/src/views/components/context-menu/ContextMenu.tsx index acfe562686ce..8822ab3ffcc2 100644 --- a/packages/ui/src/views/components/context-menu/ContextMenu.tsx +++ b/packages/ui/src/views/components/context-menu/ContextMenu.tsx @@ -15,12 +15,15 @@ */ import type { IMouseEvent } from '@univerjs/engine-render'; +import type { IContextMenuTriggerContext } from '../../../services/contextmenu/contextmenu.service'; import type { IContextMenuAnchorRect } from './AnchoredContextMenu'; import { ICommandService } from '@univerjs/core'; import { useEffect, useRef, useState } from 'react'; import { IContextMenuService } from '../../../services/contextmenu/contextmenu.service'; import { ILayoutService } from '../../../services/layout/layout.service'; -import { useDependency, useInjector } from '../../../utils/di'; +import { IMenuManagerService } from '../../../services/menu/menu-manager.service'; +import { IUIRuntimeScopeService } from '../../../services/runtime-scope/ui-runtime-scope.service'; +import { useDependency } from '../../../utils/di'; import { AnchoredContextMenu } from './AnchoredContextMenu'; const DESKTOP_CONTEXT_MENU_HOST_ID = 'desktop-context-menu'; @@ -29,10 +32,13 @@ export function DesktopContextMenu() { const [visible, setVisible] = useState(false); const [menuType, setMenuType] = useState(''); const [anchorRect, setAnchorRect] = useState(null); + const [menuContext, setMenuContext] = useState(); const visibleRef = useRef(visible); const contextMenuService = useDependency(IContextMenuService); const commandService = useDependency(ICommandService); - const injector = useInjector(); + const layoutService = useDependency(ILayoutService); + const menuManagerService = useDependency(IMenuManagerService); + const runtimeScopeService = useDependency(IUIRuntimeScopeService); visibleRef.current = visible; useEffect(() => { @@ -52,10 +58,11 @@ export function DesktopContextMenu() { }, [contextMenuService]); /** A function to open context menu with given position and menu type. */ - function handleContextMenu(event: IMouseEvent, menuType: string) { + function handleContextMenu(event: IMouseEvent, menuType: string, context?: IContextMenuTriggerContext) { setVisible(false); requestAnimationFrame(() => { setMenuType(menuType); + setMenuContext(context); setAnchorRect({ left: event.clientX, top: event.clientY, @@ -69,24 +76,36 @@ export function DesktopContextMenu() { setVisible(false); } + const activeScope = runtimeScopeService.get(menuContext?.unitId); + const activeMenuManagerService = activeScope?.has(IMenuManagerService) + ? activeScope.get(IMenuManagerService) + : menuManagerService; + const activeCommandService = activeScope?.has(ICommandService) + ? activeScope.get(ICommandService) + : commandService; + const activeLayoutService = activeScope?.has(ILayoutService) + ? activeScope.get(ILayoutService) + : layoutService; + return ( { const { label: id, commandId, value } = params; const rawParams = typeof params.params === 'function' ? params.params() : params.params; const commandParams = typeof rawParams === 'undefined' ? { value } : rawParams; - if (commandService) { - commandService.executeCommand(commandId ?? id as string, commandParams); + if (activeCommandService) { + activeCommandService.executeCommand(commandId ?? id as string, commandParams); } - const layoutService = injector.get(ILayoutService); - layoutService.focus(); + activeLayoutService.focus(); handleClose(); }} diff --git a/packages/ui/src/views/components/context-menu/ContextMenuPanel.tsx b/packages/ui/src/views/components/context-menu/ContextMenuPanel.tsx index 81a61c2a0b8d..fc5d15fe0f36 100644 --- a/packages/ui/src/views/components/context-menu/ContextMenuPanel.tsx +++ b/packages/ui/src/views/components/context-menu/ContextMenuPanel.tsx @@ -43,6 +43,8 @@ type ContextMenuAutoFocusTarget = 'first-item' | 'container'; interface IContextMenuPanelProps { menuType: string; + menuManagerService?: IMenuManagerService; + layoutService?: ILayoutService; menuSessionVersion?: number; className?: string; activeItemIds?: string[]; @@ -59,6 +61,7 @@ interface IContextMenuPanelProps { interface IContextMenuMenuProps { menuSchemas: IMenuSchema[]; + menuManagerService: IMenuManagerService; menuSessionVersion: number; submenuPortalContainer: HTMLElement | null; rootMenuElement: HTMLElement | null; @@ -75,6 +78,7 @@ interface IContextMenuMenuProps { interface IContextMenuMenuItemProps { menuKey: string; menuItem: IDisplayMenuItem; + menuManagerService: IMenuManagerService; menuSessionVersion: number; submenuPortalContainer: HTMLElement | null; rootMenuElement: HTMLElement | null; @@ -643,6 +647,8 @@ function getContextMenuSubmenuPanelClassName(sizeVariant: ContextMenuSizeVariant export function ContextMenuPanel(props: IContextMenuPanelProps) { const { menuType, + menuManagerService: providedMenuManagerService, + layoutService: providedLayoutService, menuSessionVersion = 0, className, activeItemIds, @@ -656,8 +662,10 @@ export function ContextMenuPanel(props: IContextMenuPanelProps) { onMenuPointerLeave, onOptionSelect, } = props; - const menuManagerService = useDependency(IMenuManagerService); - const layoutService = useDependency(ILayoutService); + const rootMenuManagerService = useDependency(IMenuManagerService); + const rootLayoutService = useDependency(ILayoutService); + const menuManagerService = providedMenuManagerService ?? rootMenuManagerService; + const layoutService = providedLayoutService ?? rootLayoutService; const [menuElement, setMenuElement] = useState(null); const [maxMenuHeight, setMaxMenuHeight] = useState(() => { if (typeof window === 'undefined') { @@ -807,6 +815,7 @@ export function ContextMenuPanel(props: IContextMenuPanelProps) { > (null); @@ -923,6 +932,7 @@ function ContextMenuMenu(props: IContextMenuMenuProps) { key={menuSchema.key} menuKey={menuSchema.key} menuItem={menuSchema.item as IDisplayMenuItem} + menuManagerService={menuManagerService} menuSessionVersion={menuSessionVersion} submenuPortalContainer={submenuPortalContainer} rootMenuElement={rootMenuElement} @@ -964,6 +974,7 @@ function ContextMenuMenu(props: IContextMenuMenuProps) { key={childSchema.key} menuKey={childSchema.key} menuItem={childSchema.item as IDisplayMenuItem} + menuManagerService={menuManagerService} menuSessionVersion={menuSessionVersion} submenuPortalContainer={submenuPortalContainer} rootMenuElement={rootMenuElement} @@ -1000,6 +1011,7 @@ function ContextMenuMenu(props: IContextMenuMenuProps) { key={childSchema.key} menuKey={childSchema.key} menuItem={childSchema.item as IDisplayMenuItem} + menuManagerService={menuManagerService} menuSessionVersion={menuSessionVersion} submenuPortalContainer={submenuPortalContainer} rootMenuElement={rootMenuElement} @@ -1045,6 +1057,7 @@ function ContextMenuMenu(props: IContextMenuMenuProps) { } + menuManagerService={menuManagerService} menuSessionVersion={menuSessionVersion} submenuPortalContainer={submenuPortalContainer} rootMenuElement={rootMenuElement} @@ -1070,6 +1083,7 @@ function ContextMenuMenuItem(props: IContextMenuMenuItemProps) { const { menuKey, menuItem, + menuManagerService, menuSessionVersion, submenuPortalContainer, rootMenuElement, @@ -1088,7 +1102,6 @@ function ContextMenuMenuItem(props: IContextMenuMenuItemProps) { } = props; const localeService = useDependency(LocaleService); const direction = useObservable(localeService.direction$); - const menuManagerService = useDependency(IMenuManagerService); const disabled = useObservable(menuItem.disabled$, false); const activated = useObservable(menuItem.activated$, false); const hidden = useObservable(menuItem.hidden$, false); @@ -1522,6 +1535,7 @@ function ContextMenuMenuItem(props: IContextMenuMenuItemProps) { {hasSubItemSubmenu && ( (); const visibleRef = useRef(visible); const contextMenuHostService = useDependency(IContextMenuHostService); const contextMenuService = useDependency(IContextMenuService); const commandService = useDependency(ICommandService); const layoutService = useDependency(ILayoutService); + const menuManagerService = useDependency(IMenuManagerService); + const runtimeScopeService = useDependency(IUIRuntimeScopeService); const localeService = useDependency(LocaleService); const direction = useObservable(localeService.direction$); const { mountContainer } = useContext(ConfigContext); @@ -66,9 +72,10 @@ export function MobileContextMenu() { }; }, [contextMenuHostService, contextMenuService]); - function handleContextMenu(_event: IMouseEvent, nextMenuType: string) { + function handleContextMenu(_event: IMouseEvent, nextMenuType: string, context?: IContextMenuTriggerContext) { contextMenuHostService.activateMenu(MOBILE_CONTEXT_MENU_HOST_ID); setMenuType(nextMenuType); + setMenuContext(context); setVisible(true); } @@ -92,6 +99,36 @@ export function MobileContextMenu() { return null; } + const activeScope = runtimeScopeService.get(menuContext?.unitId); + const activeCommandService = activeScope?.has(ICommandService) + ? activeScope.get(ICommandService) + : commandService; + const activeLayoutService = activeScope?.has(ILayoutService) + ? activeScope.get(ILayoutService) + : layoutService; + const activeMenuManagerService = activeScope?.has(IMenuManagerService) + ? activeScope.get(IMenuManagerService) + : menuManagerService; + const menu = ( + { + const commandId = params.commandId ?? params.id ?? params.label as string | undefined; + const fallbackParams = typeof params.params === 'function' ? params.params() : params.params; + const commandParams = typeof params.value === 'undefined' ? fallbackParams : { value: params.value }; + + if (!commandId) { + return; + } + + activeLayoutService.focus(); + activeCommandService.executeCommand(commandId, commandParams); + handleClose(); + }} + /> + ); + return createPortal(
{menuType && ( - { - const commandId = params.commandId ?? params.id ?? params.label as string | undefined; - const fallbackParams = typeof params.params === 'function' ? params.params() : params.params; - const commandParams = typeof params.value === 'undefined' ? fallbackParams : { value: params.value }; - - if (!commandId) { - return; - } - - layoutService.focus(); - commandService.executeCommand(commandId, commandParams); - handleClose(); - }} - /> + menu )}
, diff --git a/packages/ui/src/views/components/context-menu/__tests__/ContextMenuPanel.spec.tsx b/packages/ui/src/views/components/context-menu/__tests__/ContextMenuPanel.spec.tsx index d3712fdf43f1..537b162457bb 100644 --- a/packages/ui/src/views/components/context-menu/__tests__/ContextMenuPanel.spec.tsx +++ b/packages/ui/src/views/components/context-menu/__tests__/ContextMenuPanel.spec.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import type { ReactElement } from 'react'; +import type { ComponentType, ReactElement } from 'react'; import type { IValueOption } from '../../../../services/menu/menu'; import type { IMenuSchema } from '../../../../services/menu/menu-manager.service'; import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; @@ -25,7 +25,7 @@ import { ComponentManager, IconManager } from '../../../../common'; import { ILayoutService } from '../../../../services/layout/layout.service'; import { MenuItemType } from '../../../../services/menu/menu'; import { IMenuManagerService } from '../../../../services/menu/menu-manager.service'; -import { RediContext } from '../../../../utils/di'; +import { connectInjector } from '../../../../utils/di'; import { CONTEXT_MENU_SUBMENU_CLOSE_DELAY, CONTEXT_MENU_SUBMENU_PORTAL_ATTR, @@ -89,7 +89,7 @@ class TestState { } } -function createContextMenuInjector() { +function createContextMenuTestInjector() { const injector = new Injector(); injector.add([IMenuManagerService, { useClass: TestMenuManagerService as never }]); injector.add([ILayoutService, { useClass: TestLayoutService as never }]); @@ -113,17 +113,14 @@ function renderWithDependencies( menuMap: Record, setupInjector?: (injector: Injector) => void ) { - const injector = createContextMenuInjector(); + const injector = createContextMenuTestInjector(); setupInjector?.(injector); const menuManagerService = injector.get(IMenuManagerService) as unknown as TestMenuManagerService; Object.entries(menuMap).forEach(([position, menus]) => menuManagerService.setMenus(position, menus)); - return render( - - {element} - - ); + const ConnectedTestRoot = connectInjector(() => element, injector) as ComponentType; + return render(); } function createButtonItem( diff --git a/packages/ui/src/views/components/dom/FloatDom.tsx b/packages/ui/src/views/components/dom/FloatDom.tsx index ad905ac55471..f7384266a89c 100644 --- a/packages/ui/src/views/components/dom/FloatDom.tsx +++ b/packages/ui/src/views/components/dom/FloatDom.tsx @@ -20,7 +20,7 @@ import { DocumentDataModel, IUniverInstanceService } from '@univerjs/core'; import { memo, useEffect, useMemo, useRef } from 'react'; import { distinctUntilChanged, first } from 'rxjs'; import { ComponentManager } from '../../../common'; -import { CanvasFloatDomService } from '../../../services/dom/canvas-dom-layer.service'; +import { CanvasFloatDomService, shouldForwardFloatDomEvents, shouldRenderFloatDomLayer } from '../../../services/dom/canvas-dom-layer.service'; import { useDependency, useObservable } from '../../../utils/di'; export const FloatDomSingle = memo((props: { layer: IFloatDom; id: string }) => { @@ -48,7 +48,9 @@ export const FloatDomSingle = memo((props: { layer: IFloatDom; id: string }) => const layerProps: any = useMemo(() => ({ data: layer.data, ...layer.props, - }), [layer.data, layer.props]); + hostFloatDomLayout$: layer.position$, + }), [layer.data, layer.position$, layer.props]); + const floatDomOverflow = resolveFloatDomOverflow(layerProps); useEffect(() => { const subscription = layer.position$.subscribe((position) => { @@ -129,27 +131,35 @@ export const FloatDomSingle = memo((props: { layer: IFloatDom; id: string }) => width: Math.max(position.endX - position.startX - 2, 0), height: Math.max(position.endY - position.startY - 2, 0), transform: transformRef.current, - overflow: 'hidden', + overflow: floatDomOverflow.outerOverflow, transformOrigin: 'center center', }} onPointerMove={(e) => { - layer.onPointerMove(e.nativeEvent); + if (shouldForwardFloatDomEvents(layer)) { + layer.onPointerMove(e.nativeEvent); + } }} onPointerDown={(e) => { - layer.onPointerDown(e.nativeEvent); + if (shouldForwardFloatDomEvents(layer)) { + layer.onPointerDown(e.nativeEvent); + } }} onPointerUp={(e) => { - layer.onPointerUp(e.nativeEvent); + if (shouldForwardFloatDomEvents(layer)) { + layer.onPointerUp(e.nativeEvent); + } }} onWheel={(e) => { - layer.onWheel(e.nativeEvent); + if (shouldForwardFloatDomEvents(layer)) { + layer.onWheel(e.nativeEvent); + } }} >
{component}
@@ -162,9 +172,9 @@ export const FloatDom = ({ unitId }: { unitId?: string }) => { const domLayerService = useDependency(CanvasFloatDomService); const layers = useObservable(domLayerService.domLayers$); const focusUnit = useObservable(instanceService.focused$); - const currentUnitId = unitId || focusUnit; + const currentUnitId = resolveFloatDomCurrentUnitId(unitId, focusUnit); - return layers?.filter((layer) => layer[1].unitId === currentUnitId)?.map((layer) => ( + return layers?.filter((layer) => shouldRenderFloatDomLayer(layer[1], currentUnitId))?.map((layer) => ( { /> )); }; + +export function resolveFloatDomCurrentUnitId(unitId: string | undefined, focusedUnit: unknown): string | null { + if (typeof unitId === 'string') { + return unitId; + } + + if (typeof focusedUnit === 'string') { + return focusedUnit; + } + + if ( + focusedUnit != null && + typeof focusedUnit === 'object' && + 'getUnitId' in focusedUnit && + typeof focusedUnit.getUnitId === 'function' + ) { + const focusedUnitId = focusedUnit.getUnitId(); + return typeof focusedUnitId === 'string' ? focusedUnitId : null; + } + + return null; +} + +export function resolveFloatDomOverflow(props: { + customBlockRenderViewport?: { + bleedLeft?: number; + bleedWidth?: number; + }; +}): { outerOverflow: CSSProperties['overflow']; innerOverflow: CSSProperties['overflow'] } { + const viewport = props.customBlockRenderViewport; + const hasBleedViewport = Number.isFinite(viewport?.bleedWidth) && (viewport?.bleedWidth ?? 0) > 0; + if (!hasBleedViewport) { + return { + outerOverflow: 'hidden', + innerOverflow: 'hidden', + }; + } + + return { + outerOverflow: 'visible', + innerOverflow: 'visible', + }; +} diff --git a/packages/ui/src/views/components/dom/Print.tsx b/packages/ui/src/views/components/dom/Print.tsx index 9276c89cf359..394a5731b1ed 100644 --- a/packages/ui/src/views/components/dom/Print.tsx +++ b/packages/ui/src/views/components/dom/Print.tsx @@ -19,6 +19,7 @@ import { IUniverInstanceService } from '@univerjs/core'; import { useDependency } from '@wendellhu/redi/react-bindings'; import { memo, useMemo, useRef } from 'react'; import { ComponentManager } from '../../../common'; +import { shouldForwardFloatDomEvents } from '../../../services/dom/canvas-dom-layer.service'; export const PrintFloatDomSingle = memo((props: { layer: IFloatDom; id: string; position: IFloatDomLayout }) => { const { layer, id, position } = props; @@ -80,16 +81,24 @@ export const PrintFloatDomSingle = memo((props: { layer: IFloatDom; id: string; transform: transformRef.current, }} onPointerMove={(e) => { - layer.onPointerMove(e.nativeEvent); + if (shouldForwardFloatDomEvents(layer)) { + layer.onPointerMove(e.nativeEvent); + } }} onPointerDown={(e) => { - layer.onPointerDown(e.nativeEvent); + if (shouldForwardFloatDomEvents(layer)) { + layer.onPointerDown(e.nativeEvent); + } }} onPointerUp={(e) => { - layer.onPointerUp(e.nativeEvent); + if (shouldForwardFloatDomEvents(layer)) { + layer.onPointerUp(e.nativeEvent); + } }} onWheel={(e) => { - layer.onWheel(e.nativeEvent); + if (shouldForwardFloatDomEvents(layer)) { + layer.onWheel(e.nativeEvent); + } }} >
float content
; +} + +function renderWithDependencies(element: ReactElement, focusedUnit: unknown = null) { + const injector = new Injector(); + injector.add([CanvasFloatDomService]); + injector.add([IUniverInstanceService, { + useValue: { + focused$: new BehaviorSubject(focusedUnit) as never, + getUnit: () => undefined, + } as never, + }]); + + const ConnectedTestRoot = connectInjector(() => element, injector) as ComponentType; + const result = render(); + + return { + ...result, + injector, + }; +} + +function createFloatDom(): IFloatDom { + return { + id: 'float-1', + componentKey: TestFloatDomContent, + onPointerDown: () => {}, + onPointerMove: () => {}, + onPointerUp: () => {}, + onWheel: () => {}, + position$: new BehaviorSubject({ + startX: 10, + startY: 20, + endX: 110, + endY: 120, + rotate: 0, + width: 100, + height: 100, + absolute: { left: true, top: true }, + }), + unitId: 'doc-1', + }; +} + +describe('resolveFloatDomOverflow', () => { + it('keeps regular float dom layers clipped', () => { + expect(resolveFloatDomOverflow({})).toEqual({ + outerOverflow: 'hidden', + innerOverflow: 'hidden', + }); + }); + + it('allows docs custom block bleed layers to escape the drawing wrapper', () => { + expect(resolveFloatDomOverflow({ + customBlockRenderViewport: { + bleedLeft: 210, + bleedWidth: 1420, + }, + })).toEqual({ + outerOverflow: 'visible', + innerOverflow: 'visible', + }); + }); +}); + +describe('resolveFloatDomCurrentUnitId', () => { + it('resolves the focused unit id when the focused observable emits a unit object', () => { + expect(resolveFloatDomCurrentUnitId(undefined, { + getUnitId: () => 'doc-1', + })).toBe('doc-1'); + }); +}); + +describe('FloatDomSingle', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('renders an existing BehaviorSubject position without waiting for a later movement event', async () => { + renderWithDependencies(); + + await waitFor(() => expect(screen.getByText('float content')).not.toBeNull()); + expect(document.getElementById('dom-1')).not.toBeNull(); + }); + + it('passes the host layout observable to float dom components', async () => { + let receivedLayout$: IFloatDom['position$'] | undefined; + function InspectFloatDomContent(props: { hostFloatDomLayout$?: IFloatDom['position$'] }) { + receivedLayout$ = props.hostFloatDomLayout$; + return
float content
; + } + const layer = { + ...createFloatDom(), + componentKey: InspectFloatDomContent, + }; + + renderWithDependencies(); + + await waitFor(() => expect(screen.getByText('float content')).not.toBeNull()); + expect(receivedLayout$).toBe(layer.position$); + }); +}); + +describe('FloatDom', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('renders layers for the focused unit when focused$ emits a unit object', async () => { + const rendered = renderWithDependencies(, { + getUnitId: () => 'doc-1', + }); + rendered.injector.get(CanvasFloatDomService).addFloatDom(createFloatDom()); + + await waitFor(() => expect(screen.getByText('float content')).not.toBeNull()); + expect(document.getElementById('float-1')).not.toBeNull(); + }); +}); diff --git a/packages/ui/src/views/components/popup/CanvasPopup.tsx b/packages/ui/src/views/components/popup/CanvasPopup.tsx index 96dd3842ce24..bc1f469d8ded 100644 --- a/packages/ui/src/views/components/popup/CanvasPopup.tsx +++ b/packages/ui/src/views/components/popup/CanvasPopup.tsx @@ -20,7 +20,7 @@ import { useEffect, useMemo, useState } from 'react'; import { animationFrameScheduler, combineLatest, map, of, throttleTime } from 'rxjs'; import { ComponentManager } from '../../../common'; import { ICanvasPopupService } from '../../../services/popup/canvas-popup.service'; -import { useDependency, useObservable, useObservableRef } from '../../../utils/di'; +import { connectInjector, useDependency, useObservable, useObservableRef } from '../../../utils/di'; import { RectPopup } from './RectPopup'; interface ISingleCanvasPopupProps { @@ -115,13 +115,16 @@ export function CanvasPopup() { return popups.map((item) => { const [key, popup] = item; const Component = componentManager.get(popup.componentKey); + const PopupComponent = Component && popup.connectorInjector + ? connectInjector(Component, popup.connectorInjector) + : Component; return ( - {Component ? : null} + {PopupComponent && } ); }); diff --git a/packages/ui/src/views/components/popup/__tests__/CanvasPopup.spec.tsx b/packages/ui/src/views/components/popup/__tests__/CanvasPopup.spec.tsx index 360aeac51285..69d120f3365b 100644 --- a/packages/ui/src/views/components/popup/__tests__/CanvasPopup.spec.tsx +++ b/packages/ui/src/views/components/popup/__tests__/CanvasPopup.spec.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import type { ReactElement } from 'react'; +import type { ComponentType, ReactElement } from 'react'; import type { IPopup } from '../../../../services/popup/canvas-popup.service'; import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { ConfigService, IConfigService, Injector, LocaleService } from '@univerjs/core'; @@ -22,7 +22,7 @@ import { BehaviorSubject } from 'rxjs'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { ComponentManager } from '../../../../common'; import { CanvasPopupService, ICanvasPopupService } from '../../../../services/popup/canvas-popup.service'; -import { RediProvider } from '../../../../utils/di'; +import { connectInjector } from '../../../../utils/di'; import { CanvasPopup } from '../CanvasPopup'; (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; @@ -48,11 +48,8 @@ function renderWithDependencies(element: ReactElement) { injector.get(ComponentManager).register('test-popup', TestPopup); - const result = render( - - {element} - - ); + const ConnectedTestRoot = connectInjector(() => element, injector) as ComponentType; + const result = render(); return { ...result, diff --git a/packages/ui/src/views/components/ribbon/Ribbon.tsx b/packages/ui/src/views/components/ribbon/Ribbon.tsx index 6bf2bc55838b..49eb2048160f 100644 --- a/packages/ui/src/views/components/ribbon/Ribbon.tsx +++ b/packages/ui/src/views/components/ribbon/Ribbon.tsx @@ -14,17 +14,17 @@ * limitations under the License. */ -import type { ComponentType } from 'react'; +import type { ComponentType, ReactNode } from 'react'; import type { RibbonType } from '../../../controllers/ui/ui.controller'; -import type { LocaleKey } from '../../../locale/types'; import type { IMenuSchema } from '../../../services/menu/menu-manager.service'; import { LocaleService, throttle } from '@univerjs/core'; -import { borderBottomClassName, clsx, divideXClassName, Dropdown } from '@univerjs/design'; +import { borderBottomClassName, clsx, ConfigContext, ConfigProvider, divideXClassName, Dropdown } from '@univerjs/design'; import { MoreVerticalIcon } from '@univerjs/icons'; -import { Fragment, useCallback, useEffect, useMemo, useRef } from 'react'; +import { Fragment, useCallback, useContext, useEffect, useMemo, useRef } from 'react'; import { RibbonPosition } from '../../../services/menu/types'; +import { IRibbonOverrideService } from '../../../services/ribbon/ribbon-override.service'; import { IRibbonService } from '../../../services/ribbon/ribbon.service'; -import { useDependency, useObservable } from '../../../utils/di'; +import { connectInjector, useDependency, useObservable } from '../../../utils/di'; import { ComponentContainer } from '../ComponentContainer'; import { ClassicMenu } from './ribbon-menu/ClassicMenu'; import { DefaultMenu } from './ribbon-menu/DefaultMenu'; @@ -36,13 +36,18 @@ interface IRibbonProps { ribbonType: RibbonType; headerMenuComponents?: Set; headerMenu?: boolean; + toolbarOnly?: boolean; + headerClassName?: string; } export function Ribbon(props: IRibbonProps) { - const { ribbonType, headerMenuComponents, headerMenu = true } = props; + const { ribbonType, headerMenuComponents, headerMenu = true, toolbarOnly = false, headerClassName } = props; - const ribbonService = useDependency(IRibbonService); + const defaultRibbonService = useDependency(IRibbonService); + const ribbonOverrideService = useDependency(IRibbonOverrideService); const localeService = useDependency(LocaleService); + const ribbonOverride = useObservable(ribbonOverrideService.override$, ribbonOverrideService.getOverride()); + const ribbonService = ribbonOverride?.ribbonService ?? defaultRibbonService; const containerRef = useRef(null!); const toolbarItemRefs = useRef { if (ribbonType === 'simple') { @@ -80,7 +86,7 @@ export function Ribbon(props: IRibbonProps) { const handleSelectTab = useCallback((group: IMenuSchema) => { toolbarItemRefs.current = {}; ribbonService.setActivatedTab(group.key); - }, []); + }, [ribbonService]); const activeGroup = useMemo(() => { const allGroups = ribbon.find((group) => group.key === activatedTab)?.children ?? []; @@ -114,6 +120,17 @@ export function Ribbon(props: IRibbonProps) { }, [collapsedIds, ribbon, activatedTab]); useEffect(() => { + if (hideToolbar) { + toolbarItemRefs.current = {}; + ribbonService.setCollapsedIds([]); + ribbonService.setFakeToolbarVisible(false); + return; + } + + if (!containerRef.current) { + return; + } + let timer: number | null = null; const observer = new ResizeObserver(throttle((entries) => { for (const entry of entries) { @@ -155,7 +172,7 @@ export function Ribbon(props: IRibbonProps) { timer && cancelAnimationFrame(timer); observer.disconnect(); }; - }, [ribbon, activatedTab]); + }, [hideToolbar, ribbon, activatedTab, ribbonService]); const fakeToolbar = useMemo(() => { return ( @@ -202,15 +219,42 @@ export function Ribbon(props: IRibbonProps) { ); }, [activeGroup.allGroups, fakeToolbarVisible]); - return ( + const embedRibbonOverrideAttributes = ribbonOverride + ? { + 'data-embed-ribbon-override': 'true', + 'data-embed-id': ribbonOverride.id, + } + : {}; + + const content = ( <>
0), + {...embedRibbonOverrideAttributes} + className={clsx('univer-relative univer-select-none', headerClassName, { + 'univer-hidden': toolbarOnly, + 'univer-h-9': !toolbarOnly && (ribbonType === 'classic' || (headerMenuComponents && headerMenuComponents.size > 0)), })} > - {ribbonType === 'classic' && ribbon.length >= 1 && ( + {!toolbarOnly && ribbonOverride?.placeholderTitle && ribbon.length === 0 && ( +
+ + {ribbonOverride.placeholderTitle} + +
+ )} + + {!toolbarOnly && ribbonType === 'classic' && ribbon.length >= 1 && ( -
1 && ribbonType !== 'classic', - }, borderBottomClassName)} - > - {ribbonType === 'collapsed' && ribbon.length >= 1 && ( - - )} - + {!hideToolbar && (
1 && ribbonType !== 'classic', + }, borderBottomClassName)} > - - {activeGroup.visibleGroups.map((groupItem) => (groupItem.children?.length || groupItem.item) && ( - + {ribbonType === 'collapsed' && ribbon.length >= 1 && ( + + )} + +
+ + {activeGroup.visibleGroups.map((groupItem) => (groupItem.children?.length || groupItem.item) && ( + +
+ {groupItem.children && groupItem.children?.map((child) => ( + child.item && + ))} +
+
+ ))} + + {/* More functions dropdown */} + {collapsedIds.length > 0 && (
- {groupItem.children && groupItem.children?.map((child) => ( - child.item && - ))} -
- - ))} - - {/* More functions dropdown */} - {collapsedIds.length > 0 && ( -
- e.preventDefault()} - overlay={( -
- {activeGroup.hiddenGroups.map((groupItem) => ( -
-
- {groupItem.children - ? groupItem.children?.map((child) => ( - child.item && - )) - : ( - groupItem.item && - )} + e.preventDefault()} + overlay={( +
+ {activeGroup.hiddenGroups.map((groupItem) => ( +
+
+ {groupItem.children + ? groupItem.children?.map((child) => ( + child.item && + )) + : ( + groupItem.item && + )} +
-
- ))} -
- )} - > -
+ )} > - - - -
- )} - + +
+
+ )} +
+
-
+ )} {/* fake toolbar */} {fakeToolbar} ); + + return ( + + {content} + + ); +} + +function RibbonOverrideRuntimeProvider(props: { + override: ReturnType; + children: ReactNode; +}) { + const { override, children } = props; + const config = useContext(ConfigContext); + const injector = override?.injector; + const ConnectedRibbonOverrideConfigProvider = useMemo( + () => injector + ? connectInjector(RibbonOverrideConfigProvider, injector as never) as ComponentType + : null, + [injector] + ); + + if (!override || !ConnectedRibbonOverrideConfigProvider) { + return children; + } + + return ( + + {children} + + ); +} + +interface IRibbonOverrideConfigProviderProps { + children: ReactNode; + locale?: unknown; + direction?: 'ltr' | 'rtl'; + mountContainer: HTMLElement | null; +} + +function RibbonOverrideConfigProvider(props: IRibbonOverrideConfigProviderProps) { + const { children, locale, direction, mountContainer } = props; + + return ( + + {children} + + ); } diff --git a/packages/ui/src/views/components/ribbon/TooltipButtonWrapper.tsx b/packages/ui/src/views/components/ribbon/TooltipButtonWrapper.tsx index b55651158738..ba8e1fdf5e33 100644 --- a/packages/ui/src/views/components/ribbon/TooltipButtonWrapper.tsx +++ b/packages/ui/src/views/components/ribbon/TooltipButtonWrapper.tsx @@ -34,6 +34,7 @@ import { import { combineLatest, of } from 'rxjs'; import { IMenuManagerService } from '../../../services/menu/menu-manager.service'; import { useDependency } from '../../../utils/di'; +import { keepInteractionInsideSameEmbedBoundary } from '../../../utils/embed-boundary'; import { CustomLabel } from '../../custom-label/CustomLabel'; const TooltipWrapperContext = createContext({ @@ -103,6 +104,14 @@ export const TooltipWrapper = forwardRef + + {children} + + + ); + return tooltipProps.title ? ( - - - {children} - - + {content} ) - : ( - - - {children} - - - ); + : content; }); export function DropdownWrapper(props: Omit, 'overlay'> & { overlay: ReactNode; align?: 'start' | 'end' | 'center' }) { @@ -244,6 +243,10 @@ export function DropdownMenuWrapper({ setDropdownVisible(visible); } + function handleEmbedBoundaryFocusOutside(event: { currentTarget: EventTarget | null; target: EventTarget | null; preventDefault: () => void }) { + keepInteractionInsideSameEmbedBoundary(event); + } + function handleOptionSelect(option: IValueOption) { onOptionSelect(option); setDropdownVisible(false); @@ -372,6 +375,8 @@ export function DropdownMenuWrapper({ disabled={disabled} open={dropdownVisible} onOpenChange={handleVisibleChange} + onFocusOutside={handleEmbedBoundaryFocusOutside} + onInteractOutside={handleEmbedBoundaryFocusOutside} > {children} @@ -420,6 +425,8 @@ export function DropdownMenuWrapper({ disabled={disabled} open={dropdownVisible} onOpenChange={handleVisibleChange} + onFocusOutside={handleEmbedBoundaryFocusOutside} + onInteractOutside={handleEmbedBoundaryFocusOutside} > {children} diff --git a/packages/ui/src/views/components/ribbon/__tests__/RibbonOverride.spec.tsx b/packages/ui/src/views/components/ribbon/__tests__/RibbonOverride.spec.tsx new file mode 100644 index 000000000000..7785bbe3d4eb --- /dev/null +++ b/packages/ui/src/views/components/ribbon/__tests__/RibbonOverride.spec.tsx @@ -0,0 +1,99 @@ +/** + * 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. + */ + +/** + * @vitest-environment jsdom + */ + +import type { ComponentType } from 'react'; +import { cleanup, render } from '@testing-library/react'; +import { Injector, LocaleService } from '@univerjs/core'; +import { of } from 'rxjs'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ComponentManager } from '../../../../common'; +import { IRibbonOverrideService } from '../../../../services/ribbon/ribbon-override.service'; +import { IRibbonService } from '../../../../services/ribbon/ribbon.service'; +import { connectInjector } from '../../../../utils/di'; +import { Ribbon } from '../Ribbon'; + +describe('Ribbon override chrome', () => { + let observeCount = 0; + + beforeEach(() => { + observeCount = 0; + vi.stubGlobal('ResizeObserver', class { + observe() { + observeCount += 1; + } + + disconnect() {} + }); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + }); + + it('renders a centered title-only placeholder without the empty toolbar row', () => { + const injector = new Injector([ + [ComponentManager], + [LocaleService, { useValue: { t: (key: string) => key } }], + [IRibbonService, { useValue: createEmptyRibbonService() }], + [IRibbonOverrideService, { + useValue: { + override$: of({ + id: 'embed-1', + ribbonService: createEmptyRibbonService(), + placeholderTitle: 'Bases', + hideToolbar: true, + }), + getOverride: () => ({ + id: 'embed-1', + ribbonService: createEmptyRibbonService(), + placeholderTitle: 'Bases', + hideToolbar: true, + }), + activate: () => {}, + clear: () => {}, + }, + }], + ]); + + const ConnectedRibbon = connectInjector(Ribbon, injector) as ComponentType<{ ribbonType: 'classic' }>; + const { container, getByText } = render(); + + const placeholder = getByText('Bases'); + expect(placeholder?.parentElement?.className).toContain('univer-justify-center'); + expect(container.querySelectorAll('[data-embed-ribbon-override="true"]')).toHaveLength(1); + expect(observeCount).toBe(0); + }); +}); + +function createEmptyRibbonService() { + return { + ribbon$: of([]), + activatedTab$: of(''), + collapsedIds$: of([]), + fakeToolbarVisible$: of(false), + setActivatedTab: () => {}, + showContextualTab: () => {}, + hideContextualTab: () => {}, + hideAllContextualTabs: () => {}, + setCollapsedIds: () => {}, + setFakeToolbarVisible: () => {}, + }; +} diff --git a/packages/ui/src/views/components/ribbon/__tests__/ToolbarItem.spec.tsx b/packages/ui/src/views/components/ribbon/__tests__/ToolbarItem.spec.tsx index 4bd7048a9a33..d078757dabe6 100644 --- a/packages/ui/src/views/components/ribbon/__tests__/ToolbarItem.spec.tsx +++ b/packages/ui/src/views/components/ribbon/__tests__/ToolbarItem.spec.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import type { ReactElement } from 'react'; +import type { ComponentType, ReactElement } from 'react'; import { cleanup, render } from '@testing-library/react'; import { ICommandService, ILogService, Injector, LocaleService } from '@univerjs/core'; import { Subject } from 'rxjs'; @@ -25,7 +25,7 @@ import { ILayoutService } from '../../../../services/layout/layout.service'; import { MenuItemType } from '../../../../services/menu/menu'; import { IMenuManagerService } from '../../../../services/menu/menu-manager.service'; import { IShortcutService } from '../../../../services/shortcut/shortcut.service'; -import { RediContext } from '../../../../utils/di'; +import { connectInjector } from '../../../../utils/di'; import { ToolbarItem } from '../ToolbarItem'; class TestLocaleService { @@ -77,11 +77,8 @@ function renderWithDependencies(element: ReactElement) { TestIcon: ({ className }: { className?: string }) => , }); - return render( - - {element} - - ); + const ConnectedTestRoot = connectInjector(() => element, injector) as ComponentType; + return render(); } afterEach(cleanup); diff --git a/packages/ui/src/views/components/ribbon/__tests__/TooltipButtonWrapper.spec.tsx b/packages/ui/src/views/components/ribbon/__tests__/TooltipButtonWrapper.spec.tsx index e0260d4f095d..4ceaeca7a4cf 100644 --- a/packages/ui/src/views/components/ribbon/__tests__/TooltipButtonWrapper.spec.tsx +++ b/packages/ui/src/views/components/ribbon/__tests__/TooltipButtonWrapper.spec.tsx @@ -14,13 +14,17 @@ * limitations under the License. */ -import type { ReactElement } from 'react'; +/** + * @vitest-environment jsdom + */ + +import type { ComponentType, ReactElement } from 'react'; import { render } from '@testing-library/react'; import { ILogService, Injector, LocaleService } from '@univerjs/core'; import { describe, expect, it } from 'vitest'; import { ComponentManager } from '../../../../common/component-manager'; import { IconManager } from '../../../../common/icon-manager'; -import { RediContext } from '../../../../utils/di'; +import { connectInjector } from '../../../../utils/di'; import { DropdownMenuLabel } from '../TooltipButtonWrapper'; class TestLocaleService { @@ -40,11 +44,8 @@ function renderWithDependencies(element: ReactElement) { injector.add([ComponentManager]); injector.add([IconManager]); - return render( - - {element} - - ); + const ConnectedTestRoot = connectInjector(() => element, injector) as ComponentType; + return render(); } describe('DropdownMenuLabel', () => { diff --git a/packages/ui/src/views/menu/desktop/__tests__/TinyMenuGroup.spec.tsx b/packages/ui/src/views/menu/desktop/__tests__/TinyMenuGroup.spec.tsx index 3a57a586b014..9df71eca26fb 100644 --- a/packages/ui/src/views/menu/desktop/__tests__/TinyMenuGroup.spec.tsx +++ b/packages/ui/src/views/menu/desktop/__tests__/TinyMenuGroup.spec.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import type { ReactElement } from 'react'; +import type { ComponentType, ReactElement } from 'react'; import type { IValueOption } from '../../../../services/menu/menu'; import type { IMenuSchema } from '../../../../services/menu/menu-manager.service'; import { fireEvent, render, screen } from '@testing-library/react'; @@ -23,7 +23,7 @@ import { BehaviorSubject } from 'rxjs'; import { describe, expect, it } from 'vitest'; import { IconManager } from '../../../../common/icon-manager'; import { MenuItemType } from '../../../../services/menu/menu'; -import { RediContext } from '../../../../utils/di'; +import { connectInjector } from '../../../../utils/di'; import { getVisibleTinyMenuChildren, resolveMenuItemActiveState, UITinyMenuGroup } from '../TinyMenuGroup'; class TestLocaleService { @@ -46,7 +46,7 @@ class TestState { } } -function createTinyMenuInjector() { +function createTinyMenuTestInjector() { const injector = new Injector(); injector.add([LocaleService, { useClass: TestLocaleService as never }]); injector.add([ILogService, { useClass: TestLogService as never }]); @@ -63,11 +63,8 @@ function createTinyMenuInjector() { } function renderWithDependencies(element: ReactElement) { - return render( - - {element} - - ); + const ConnectedTestRoot = connectInjector(() => element, createTinyMenuTestInjector()) as ComponentType; + return render(); } function createChild( diff --git a/packages/ui/src/views/menu/mobile/MobileMenu.tsx b/packages/ui/src/views/menu/mobile/MobileMenu.tsx index 7183311f720e..0db0109f5478 100644 --- a/packages/ui/src/views/menu/mobile/MobileMenu.tsx +++ b/packages/ui/src/views/menu/mobile/MobileMenu.tsx @@ -52,11 +52,13 @@ type MobileMenuView = interface IMobileMenuProps extends IBaseMenuProps { schemas?: IMenuSchema[]; + menuManagerService?: IMenuManagerService; } export function MobileMenu(props: IMobileMenuProps) { - const { menuType, onOptionSelect, schemas: providedSchemas } = props; - const menuManagerService = useDependency(IMenuManagerService); + const { menuType, onOptionSelect, schemas: providedSchemas, menuManagerService: providedMenuManagerService } = props; + const rootMenuManagerService = useDependency(IMenuManagerService); + const menuManagerService = providedMenuManagerService ?? rootMenuManagerService; const [viewStack, setViewStack] = useState([]); const menuSchemaVersion$ = useMemo(() => { @@ -132,6 +134,7 @@ export function MobileMenu(props: IMobileMenuProps) {
void; }) { - const { schemas, menuType, onExecute, onOpenView } = props; + const { schemas, menuManagerService, menuType, onExecute, onOpenView } = props; const localeService = useDependency(LocaleService); const hiddenGroupStates = useContextGroupHiddenStates(schemas); @@ -183,6 +187,7 @@ function MobileSchemaList(props: { void; bordered: boolean; }) { - const { schema, menuType, onExecute, onOpenView, bordered } = props; - const interaction = useMobileSchemaInteraction({ schema, menuType, onOpenView }); + const { schema, menuManagerService, menuType, onExecute, onOpenView, bordered } = props; + const interaction = useMobileSchemaInteraction({ schema, menuManagerService, menuType, onOpenView }); if (!interaction || interaction.hidden) { return null; @@ -365,11 +372,11 @@ function MobileSelectionOptionRow(props: { function useMobileSchemaInteraction(props: { schema: IMenuSchema; + menuManagerService: IMenuManagerService; menuType?: string; onOpenView: (view: MobileMenuView) => void; }) { - const { schema, menuType, onOpenView } = props; - const menuManagerService = useDependency(IMenuManagerService); + const { schema, menuManagerService, menuType, onOpenView } = props; const localeService = useDependency(LocaleService); const menuItem = schema.item as IDisplayMenuItem | undefined; const selectorItem = menuItem as IDisplayMenuItem> | undefined; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0c6da0ed5a46..e1473273951a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4816,9 +4816,6 @@ packages: '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - '@jridgewell/trace-mapping@0.3.30': - resolution: {integrity: sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==} - '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} @@ -7315,11 +7312,6 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true - browserslist@4.28.4: - resolution: {integrity: sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==} - engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} - hasBin: true - browserslist@4.28.5: resolution: {integrity: sha512-Cu2E6QejHWzuDMTkuwgpABFgDfZrXLQq5V13YOACZx4mFAG4IwGTbTfHPMr4WtxlHoXSM8FIuRwYYCz5XiabaQ==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} @@ -7908,9 +7900,6 @@ packages: electron-to-chromium@1.5.338: resolution: {integrity: sha512-KVQQ3xko9/coDX3qXLUEEbqkKT8L+1DyAovrtu0Khtrt9wjSZ+7CZV4GVzxFy9Oe1NbrIU1oVXCwHJruIA1PNg==} - electron-to-chromium@1.5.387: - resolution: {integrity: sha512-TaxwufTFDufvPEoXdhwVrA3UdFWBeWGkYoJ1K8ldF1xe6gKfth6iRNS5lTQ5JPNOHdGQm8PT1QYKUqFLCiUefQ==} - electron-to-chromium@1.5.388: resolution: {integrity: sha512-Pl/aJaqOOxYxda3vcx1IKSJimwYXHDkEnGn0F+kG2EE68dDtx2uCinaS+Vih8Z91B9t8CSAbiF/HKyWcnXjhzw==} @@ -7987,9 +7976,6 @@ packages: es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} - es-module-lexer@2.1.0: - resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} - es-module-lexer@2.3.0: resolution: {integrity: sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==} @@ -10129,10 +10115,6 @@ packages: resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==} engines: {node: '>=4'} - postcss-selector-parser@7.1.0: - resolution: {integrity: sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==} - engines: {node: '>=4'} - postcss-selector-parser@7.1.1: resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==} engines: {node: '>=4'} @@ -11710,7 +11692,7 @@ snapshots: dependencies: '@babel/compat-data': 7.29.7 '@babel/helper-validator-option': 7.29.7 - browserslist: 4.28.4 + browserslist: 4.28.5 lru-cache: 5.1.1 semver: 6.3.1 @@ -12774,12 +12756,12 @@ snapshots: '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 - '@jridgewell/trace-mapping': 0.3.30 + '@jridgewell/trace-mapping': 0.3.31 '@jridgewell/remapping@2.3.5': dependencies: '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.30 + '@jridgewell/trace-mapping': 0.3.31 '@jridgewell/resolve-uri@3.1.2': {} @@ -12790,11 +12772,6 @@ snapshots: '@jridgewell/sourcemap-codec@1.5.5': {} - '@jridgewell/trace-mapping@0.3.30': - dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 - '@jridgewell/trace-mapping@0.3.31': dependencies: '@jridgewell/resolve-uri': 3.1.2 @@ -12818,13 +12795,6 @@ snapshots: '@types/react': 19.2.17 react: 19.2.7 - '@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0)': - dependencies: - '@emnapi/core': 1.11.0 - '@emnapi/runtime': 1.11.0 - '@tybys/wasm-util': 0.10.2 - optional: true - '@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': dependencies: '@emnapi/core': 1.11.1 @@ -12846,6 +12816,13 @@ snapshots: '@tybys/wasm-util': 0.10.3 optional: true + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0)': + dependencies: + '@emnapi/core': 1.11.0 + '@emnapi/runtime': 1.11.0 + '@tybys/wasm-util': 0.10.3 + optional: true + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)': dependencies: '@emnapi/core': 1.9.2 @@ -12994,7 +12971,7 @@ snapshots: dependencies: '@emnapi/core': 1.11.0 '@emnapi/runtime': 1.11.0 - '@napi-rs/wasm-runtime': 1.1.5(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0) + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0) optional: true '@oxc-resolver/binding-win32-arm64-msvc@11.21.3': @@ -14781,9 +14758,9 @@ snapshots: dependencies: acorn: 8.15.0 - acorn-import-phases@1.0.4(acorn@8.16.0): + acorn-import-phases@1.0.4(acorn@8.17.0): dependencies: - acorn: 8.16.0 + acorn: 8.17.0 acorn-jsx@5.3.2(acorn@8.16.0): dependencies: @@ -15059,14 +15036,6 @@ snapshots: node-releases: 2.0.37 update-browserslist-db: 1.2.3(browserslist@4.28.2) - browserslist@4.28.4: - dependencies: - baseline-browser-mapping: 2.10.42 - caniuse-lite: 1.0.30001802 - electron-to-chromium: 1.5.387 - node-releases: 2.0.50 - update-browserslist-db: 1.2.3(browserslist@4.28.4) - browserslist@4.28.5: dependencies: baseline-browser-mapping: 2.10.42 @@ -15608,8 +15577,6 @@ snapshots: electron-to-chromium@1.5.338: {} - electron-to-chromium@1.5.387: {} - electron-to-chromium@1.5.388: {} emoji-regex@10.6.0: {} @@ -15738,8 +15705,6 @@ snapshots: es-module-lexer@1.7.0: {} - es-module-lexer@2.1.0: {} - es-module-lexer@2.3.0: {} es-object-atoms@1.1.1: @@ -16370,6 +16335,10 @@ snapshots: optionalDependencies: picomatch: 4.0.4 + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 @@ -18186,13 +18155,13 @@ snapshots: dependencies: icss-utils: 5.1.0(postcss@8.5.16) postcss: 8.5.16 - postcss-selector-parser: 7.1.0 + postcss-selector-parser: 7.1.1 postcss-value-parser: 4.2.0 postcss-modules-scope@3.2.1(postcss@8.5.16): dependencies: postcss: 8.5.16 - postcss-selector-parser: 7.1.0 + postcss-selector-parser: 7.1.1 postcss-modules-values@4.0.0(postcss@8.5.16): dependencies: @@ -18345,11 +18314,6 @@ snapshots: cssesc: 3.0.0 util-deprecate: 1.0.2 - postcss-selector-parser@7.1.0: - dependencies: - cssesc: 3.0.0 - util-deprecate: 1.0.2 - postcss-selector-parser@7.1.1: dependencies: cssesc: 3.0.0 @@ -19313,8 +19277,8 @@ snapshots: tinyglobby@0.2.17: dependencies: - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 tinypool@2.1.0: {} @@ -19585,12 +19549,6 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 - update-browserslist-db@1.2.3(browserslist@4.28.4): - dependencies: - browserslist: 4.28.4 - escalade: 3.2.0 - picocolors: 1.1.1 - update-browserslist-db@1.2.3(browserslist@4.28.5): dependencies: browserslist: 4.28.5 @@ -19763,12 +19721,12 @@ snapshots: '@webassemblyjs/ast': 1.14.1 '@webassemblyjs/wasm-edit': 1.14.1 '@webassemblyjs/wasm-parser': 1.14.1 - acorn: 8.16.0 - acorn-import-phases: 1.0.4(acorn@8.16.0) - browserslist: 4.28.2 + acorn: 8.17.0 + acorn-import-phases: 1.0.4(acorn@8.17.0) + browserslist: 4.28.5 chrome-trace-event: 1.0.4 - enhanced-resolve: 5.21.0 - es-module-lexer: 2.1.0 + enhanced-resolve: 5.24.1 + es-module-lexer: 2.3.0 eslint-scope: 5.1.1 events: 3.3.0 glob-to-regexp: 0.4.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 130d6b893e4b..0faa9a003b12 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -11,6 +11,6 @@ publicHoistPattern: overrides: '@types/react': 19.2.17 '@types/react-dom': 19.2.3 - basic-ftp: 5.2.0 react: 19.2.7 react-dom: 19.2.7 +# Before `pnpm >=11` in the packageManager, this file is not allowed to be modified.