fix(docs-hyper-link-ui): respect host editor hyperlink ownership (#7754)

This commit is contained in:
Univer
2026-09-27 03:12:13 -07:00
committed by GitHub
parent 1defaf4ab6
commit cfb245a2f9
5 changed files with 164 additions and 95 deletions
@@ -24,7 +24,7 @@ import {
Inject,
} from '@univerjs/core';
import { DocSelectionManagerService, DocSkeletonManagerService } from '@univerjs/docs';
import { DocEventManagerService } from '@univerjs/docs-ui';
import { DocEventManagerService, getEditorRuntimeConfig } from '@univerjs/docs-ui';
import {
ClickDocHyperLinkOperation,
ToggleDocHyperLinkInfoPopupOperation,
@@ -46,7 +46,7 @@ export class DocHyperLinkEventRenderController extends Disposable implements IRe
) {
super();
if (this._context.unitId === DOCS_NORMAL_EDITOR_UNIT_ID_KEY) {
if (this._context.unitId === DOCS_NORMAL_EDITOR_UNIT_ID_KEY || getEditorRuntimeConfig(this._context.unit)?.customHyperLinkUI) {
return;
}
@@ -14,79 +14,126 @@
* limitations under the License.
*/
/** @vitest-environment jsdom */
import type { DocumentDataModel, IDocumentData } from '@univerjs/core';
import type { RenderUnit } from '@univerjs/engine-render';
import { IPermissionService, LocaleService, LocaleType, Univer, UniverInstanceType } from '@univerjs/core';
import {
IPermissionService,
toDisposable,
Univer,
UniverInstanceType,
} from '@univerjs/core';
import { DocSelectionManagerService, setDocumentPermissionValue } from '@univerjs/docs';
import { DocCanvasPopManagerService } from '@univerjs/docs-ui';
import { IRenderManagerService, RenderManagerService } from '@univerjs/engine-render';
DocLayoutExecutorService,
DocSelectionManagerService,
DocSkeletonManagerService,
setDocumentPermissionValue,
} from '@univerjs/docs';
import { DocCanvasPopManagerService, registerEditorRuntimeConfig } from '@univerjs/docs-ui';
import { Documents, FontCache, IRenderManagerService, UniverRenderEnginePlugin } from '@univerjs/engine-render';
import { UnitAction } from '@univerjs/protocol';
import { IDialogService } from '@univerjs/ui';
import { of } from 'rxjs';
import { describe, expect, it, vi } from 'vitest';
import {
CanvasPopupService,
DesktopDialogService,
ICanvasPopupService,
IDialogService,
IUIPartsService,
UIPartsService,
} from '@univerjs/ui';
import { afterEach, describe, expect, it, vi } from 'vitest';
import enUS from '../../locale/en-US';
import { DocHyperLinkPopupService } from '../hyper-link-popup.service';
class CapturingDocCanvasPopManagerService {
readonly attached: unknown[] = [];
readonly disposed: number[] = [];
attachPopupToRange(range: unknown, popup: unknown, unitId: string) {
this.attached.push({ range, popup, unitId });
const order = this.attached.length;
return { dispose: () => this.disposed.push(order) };
const instances = new Set<Univer>();
afterEach(() => {
for (const univer of instances) {
univer.dispose();
}
}
const CapturingDocCanvasPopManagerServiceCtor = CapturingDocCanvasPopManagerService as unknown as typeof DocCanvasPopManagerService;
instances.clear();
vi.restoreAllMocks();
vi.useRealTimers();
});
function createService() {
// Canvas is the unavailable platform boundary; document and popup services stay real.
const context = new Proxy({ webkitBackingStorePixelRatio: 1, measureText: (text: string) => ({ width: text.length * 8, actualBoundingBoxAscent: 10, actualBoundingBoxDescent: 2, fontBoundingBoxAscent: 10, fontBoundingBoxDescent: 2 }) }, {
get: (target, key) => key in target ? Reflect.get(target, key) : () => {},
});
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(context as never);
Reflect.set(FontCache, '_context', null);
FontCache.invalidateMetrics(() => true);
const univer = new Univer();
instances.add(univer);
univer.registerPlugin(UniverRenderEnginePlugin);
const injector = univer.__getInjector();
injector.add([IDialogService, {
useValue: {
close: () => {},
closeAll: () => {},
getDialogs$: () => of([]),
open: () => toDisposable(() => {}),
},
}]);
injector.add([DocCanvasPopManagerService, { useClass: CapturingDocCanvasPopManagerServiceCtor }]);
injector.add([IRenderManagerService, { useClass: RenderManagerService }]);
injector.add([IUIPartsService, { useClass: UIPartsService }]);
injector.add([IDialogService, { useClass: DesktopDialogService }]);
injector.add([ICanvasPopupService, { useClass: CanvasPopupService }]);
injector.add([DocCanvasPopManagerService]);
injector.add([DocLayoutExecutorService]);
injector.add([DocSelectionManagerService]);
injector.add([DocHyperLinkPopupService]);
univer.createUnit(UniverInstanceType.UNIVER_DOC, { id: 'doc-1' });
const locale = injector.get(LocaleService);
locale.load({ [LocaleType.EN_US]: enUS });
locale.setLocale(LocaleType.EN_US);
locale.setDirection('ltr');
const model = univer.createUnit<IDocumentData, DocumentDataModel>(UniverInstanceType.UNIVER_DOC, {
id: 'doc-1',
body: { dataStream: 'first link and second link\r\n', paragraphs: [{ startIndex: 26, paragraphId: 'paragraph' }], sectionBreaks: [{ startIndex: 27, sectionId: 'section' }] },
documentStyle: { pageSize: { width: 400, height: 300 } },
});
univer.createUnit(UniverInstanceType.UNIVER_DOC, { id: 'doc-2' });
const manager = injector.get(IRenderManagerService);
const render = manager.createRender('doc-1') as RenderUnit;
render.deactivate();
render.engine.resizeBySize(500, 500);
vi.spyOn(render.engine.getCanvasElement(), 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 0, 500, 500));
render.addRenderDependencies([[DocSkeletonManagerService]]);
const documents = new Documents('doc-1', render.with(DocSkeletonManagerService).getSkeleton());
render.mainComponent = documents;
render.scene.addObject(documents);
manager.createRender('doc-2').deactivate();
const selectionManager = injector.get(DocSelectionManagerService);
selectionManager.__TEST_ONLY_setCurrentSelection({ unitId: 'doc-1', subUnitId: 'doc-1' });
const popupManager = injector.get(DocCanvasPopManagerService) as unknown as CapturingDocCanvasPopManagerService;
const permissionService = injector.get(IPermissionService);
const renderManagerService = injector.get(IRenderManagerService);
const attach = vi.spyOn(injector.get(DocCanvasPopManagerService), 'attachPopupToRange');
const canvasPopup = injector.get(ICanvasPopupService);
const add = vi.spyOn(canvasPopup, 'addPopup');
const remove = vi.spyOn(canvasPopup, 'removePopup');
return {
service: injector.get(DocHyperLinkPopupService),
injector,
registerCustomHyperLinkUI: () => registerEditorRuntimeConfig(model, { customHyperLinkUI: true }),
dispose: () => univer.dispose(),
selectionManager,
attached: popupManager.attached,
disposed: popupManager.disposed,
disposeRender: (unitId: string) => {
renderManagerService.addRender(unitId, {
unitId,
type: UniverInstanceType.UNIVER_DOC,
components: new Map(),
engine: { dispose: () => {} },
scene: { dispose: () => {} },
dispose: () => {},
} as never);
renderManagerService.removeRender(unitId);
},
permissionService,
attached: () => attach.mock.calls.map(([range, popup, unitId]) => ({ range, popup, unitId })),
disposed: () => remove.mock.calls.map(([id]) => add.mock.results.findIndex((result) => result.value === id) + 1),
disposeRender: (unitId: string) => manager.removeRender(unitId),
permissionService: injector.get(IPermissionService),
};
}
describe('DocHyperLinkPopupService', () => {
it('leaves hyperlink menus to the host editor and restores default behavior after unregistering', () => {
const { injector, attached, registerCustomHyperLinkUI, dispose } = createService();
const service = injector.get(DocHyperLinkPopupService);
const ownership = registerCustomHyperLinkUI();
const link = { unitId: 'doc-1', linkId: 'link-1', startIndex: 0, endIndex: 3 };
try {
service.showInfoPopup(link);
service.showInfoPopupFromHover(link);
service.showInfoPopup(link, { pinned: true });
service.showEditPopup('doc-1', link);
expect(attached()).toHaveLength(0);
expect(service.showing).toBeNull();
expect(service.editing).toBeNull();
ownership.dispose();
service.showInfoPopup(link);
expect(attached()).toHaveLength(1);
expect(service.showing?.linkId).toBe('link-1');
} finally {
ownership.dispose();
dispose();
}
});
it('closes link popups only when their owning Render is disposed', () => {
const { service, disposed, disposeRender } = createService();
const { injector, disposed, disposeRender } = createService();
const service = injector.get(DocHyperLinkPopupService);
const link = {
unitId: 'doc-1',
linkId: 'link-1',
@@ -97,14 +144,15 @@ describe('DocHyperLinkPopupService', () => {
service.showEditPopup('doc-1', link);
service.showInfoPopup(link);
disposeRender('doc-2');
expect(disposed).toEqual([]);
expect(disposed()).toEqual([]);
disposeRender('doc-1');
expect(disposed).toEqual([1, 2]);
expect(disposed()).toEqual([1, 2]);
});
it('releases owned link popups when the service is disposed', () => {
const { service, disposed } = createService();
const { injector, disposed } = createService();
const service = injector.get(DocHyperLinkPopupService);
const link = {
unitId: 'doc-1',
linkId: 'link-1',
@@ -116,11 +164,12 @@ describe('DocHyperLinkPopupService', () => {
service.showInfoPopup(link);
service.dispose();
expect(disposed).toEqual([1, 2]);
expect(disposed()).toEqual([1, 2]);
});
it('opens edit and info popups around the selected document link and disposes previous popups', () => {
const { service, selectionManager, attached, disposed } = createService();
const { injector, selectionManager, attached, disposed } = createService();
const service = injector.get(DocHyperLinkPopupService);
const refreshes: unknown[] = [];
const refreshSub = selectionManager.refreshSelection$.subscribe((value) => refreshes.push(value));
const link = {
@@ -133,7 +182,7 @@ describe('DocHyperLinkPopupService', () => {
service.showEditPopup('doc-1', link);
expect(service.editing).toEqual(link);
expect(refreshes.at(-1)).toMatchObject({ docRanges: [{ startOffset: 4, endOffset: 9 }] });
expect(attached).toMatchObject([{
expect(attached()).toMatchObject([{
range: { startOffset: 4, endOffset: 9, collapsed: false },
popup: { offset: [0, 10] },
unitId: 'doc-1',
@@ -141,7 +190,7 @@ describe('DocHyperLinkPopupService', () => {
service.showInfoPopup(link);
expect(service.showing).toEqual(link);
expect(attached.at(-1)).toMatchObject({
expect(attached().at(-1)).toMatchObject({
range: { startOffset: 4, endOffset: 9, collapsed: false },
popup: { offset: [0, 10] },
unitId: 'doc-1',
@@ -151,13 +200,14 @@ describe('DocHyperLinkPopupService', () => {
service.hideInfoPopup();
expect(service.editing).toBeNull();
expect(service.showing).toBeNull();
expect(disposed).toEqual([1, 2]);
expect(disposed()).toEqual([1, 2]);
refreshSub.unsubscribe();
});
it('opens the edit popup from the current text selection when creating a new hyperlink', () => {
const { service, selectionManager, attached, disposed } = createService();
const { injector, selectionManager, attached, disposed } = createService();
const service = injector.get(DocHyperLinkPopupService);
selectionManager.__TEST_ONLY_add([{
startOffset: 1,
endOffset: 5,
@@ -172,15 +222,16 @@ describe('DocHyperLinkPopupService', () => {
expect(firstPopup).not.toBeNull();
expect(secondPopup).not.toBeNull();
expect(service.editing).toBeNull();
expect(attached).toMatchObject([
expect(attached()).toMatchObject([
{ range: { startOffset: 1, endOffset: 5, collapsed: false }, unitId: 'doc-1' },
{ range: { startOffset: 1, endOffset: 5, collapsed: false }, unitId: 'doc-1' },
]);
expect(disposed).toEqual([1]);
expect(disposed()).toEqual([1]);
});
it('reuses an already visible link popup and replaces it only when the hovered link changes', () => {
const { service, attached, disposed } = createService();
const { injector, attached, disposed } = createService();
const service = injector.get(DocHyperLinkPopupService);
const firstLink = {
unitId: 'doc-1',
linkId: 'link-1',
@@ -201,13 +252,14 @@ describe('DocHyperLinkPopupService', () => {
expect(firstPopup).not.toBeNull();
expect(duplicatePopup).toBeUndefined();
expect(secondPopup).not.toBeNull();
expect(attached).toHaveLength(2);
expect(attached()).toHaveLength(2);
expect(service.showing).toEqual(secondLink);
expect(disposed).toEqual([1]);
expect(disposed()).toEqual([1]);
});
it('keeps a click-pinned popup pinned until another link replaces it or it closes', () => {
const { service } = createService();
const { injector } = createService();
const service = injector.get(DocHyperLinkPopupService);
const firstLink = {
unitId: 'doc-1',
linkId: 'link-1',
@@ -237,7 +289,8 @@ describe('DocHyperLinkPopupService', () => {
it('keeps a hovered popup visible while the pointer crosses to it and closes after the handoff delay', () => {
vi.useFakeTimers();
const { service, disposed } = createService();
const { injector, disposed } = createService();
const service = injector.get(DocHyperLinkPopupService);
const link = {
unitId: 'doc-1',
linkId: 'link-1',
@@ -258,7 +311,7 @@ describe('DocHyperLinkPopupService', () => {
service.scheduleHideInfoPopup();
vi.advanceTimersByTime(150);
expect(service.showing).toBeNull();
expect(disposed).toEqual([1]);
expect(disposed()).toEqual([1]);
} finally {
service.dispose();
vi.useRealTimers();
@@ -266,7 +319,8 @@ describe('DocHyperLinkPopupService', () => {
});
it('keeps the current popup while the pointer crosses adjacent links', () => {
const { service, attached, disposed } = createService();
const { injector, attached, disposed } = createService();
const service = injector.get(DocHyperLinkPopupService);
const firstLink = { unitId: 'doc-1', linkId: 'link-1', startIndex: 4, endIndex: 8 };
const secondLink = { unitId: 'doc-1', linkId: 'link-2', startIndex: 9, endIndex: 13 };
@@ -274,20 +328,21 @@ describe('DocHyperLinkPopupService', () => {
service.showInfoPopupFromHover(secondLink);
expect(service.showing).toEqual(firstLink);
expect(attached).toHaveLength(1);
expect(disposed).toEqual([]);
expect(attached()).toHaveLength(1);
expect(disposed()).toEqual([]);
service.hideInfoPopup();
service.showInfoPopupFromHover(secondLink);
expect(service.showing).toEqual(secondLink);
expect(attached).toHaveLength(2);
expect(disposed).toEqual([1]);
expect(attached()).toHaveLength(2);
expect(disposed()).toEqual([1]);
service.dispose();
});
it('does not show link information when the target document is not loaded', () => {
const { service, attached } = createService();
const { injector, attached } = createService();
const service = injector.get(DocHyperLinkPopupService);
const popup = service.showInfoPopup({
unitId: 'missing-doc',
@@ -298,11 +353,12 @@ describe('DocHyperLinkPopupService', () => {
expect(popup).toBeUndefined();
expect(service.showing).toBeNull();
expect(attached).toEqual([]);
expect(attached()).toEqual([]);
});
it('hides the link information popup when the user clicks outside it', () => {
const { service, attached, disposed } = createService();
const { injector, attached, disposed } = createService();
const service = injector.get(DocHyperLinkPopupService);
const link = {
unitId: 'doc-1',
linkId: 'link-1',
@@ -311,15 +367,15 @@ describe('DocHyperLinkPopupService', () => {
};
service.showInfoPopup(link);
const popupConfig = (attached[0] as { popup: { onClickOutside: () => void } }).popup;
popupConfig.onClickOutside();
attached()[0].popup.onClickOutside?.(new MouseEvent('click'));
expect(service.showing).toBeNull();
expect(disposed).toEqual([1]);
expect(disposed()).toEqual([1]);
});
it('prevents a stale selection refresh from restoring a popup closed on pointer down', async () => {
const { service, attached, disposed } = createService();
const { injector, attached, disposed } = createService();
const service = injector.get(DocHyperLinkPopupService);
const link = {
unitId: 'doc-1',
linkId: 'link-1',
@@ -332,18 +388,19 @@ describe('DocHyperLinkPopupService', () => {
service.showInfoPopup(link);
expect(service.showing).toBeNull();
expect(attached).toHaveLength(1);
expect(disposed).toEqual([1]);
expect(attached()).toHaveLength(1);
expect(disposed()).toEqual([1]);
await new Promise((resolve) => setTimeout(resolve, 0));
service.showInfoPopup(link);
expect(service.showing).toEqual(link);
expect(attached).toHaveLength(2);
expect(attached()).toHaveLength(2);
});
it('does not open an edit popup without document edit permission', () => {
const { service, attached, permissionService } = createService();
const { injector, attached, permissionService } = createService();
const service = injector.get(DocHyperLinkPopupService);
setDocumentPermissionValue(
permissionService,
'doc-1',
@@ -353,6 +410,6 @@ describe('DocHyperLinkPopupService', () => {
);
expect(service.showEditPopup('doc-1', null)).toBeNull();
expect(attached).toEqual([]);
expect(attached()).toEqual([]);
});
});
@@ -25,8 +25,13 @@ import {
LocaleService,
UniverInstanceType,
} from '@univerjs/core';
import { canEditDocumentTargets, DocSelectionManagerService, getDocumentEntityParentPermissionObjectIds, getDocumentEntityPermissionObjectId } from '@univerjs/docs';
import { DocCanvasPopManagerService } from '@univerjs/docs-ui';
import {
canEditDocumentTargets,
DocSelectionManagerService,
getDocumentEntityParentPermissionObjectIds,
getDocumentEntityPermissionObjectId,
} from '@univerjs/docs';
import { DocCanvasPopManagerService, getEditorRuntimeConfig } from '@univerjs/docs-ui';
import { IRenderManagerService } from '@univerjs/engine-render';
import { IDialogService } from '@univerjs/ui';
import { BehaviorSubject } from 'rxjs';
@@ -128,6 +133,10 @@ export class DocHyperLinkPopupService extends Disposable {
}
showEditPopup(unitId: string, linkInfo: ILinkInfo | null): IDisposable | null {
const doc = this._univerInstanceService.getUnit<DocumentDataModel>(unitId, UniverInstanceType.UNIVER_DOC);
if (doc && getEditorRuntimeConfig(doc)?.customHyperLinkUI) {
return null;
}
if (!this.canEditLink(unitId, linkInfo)) {
return null;
}
@@ -174,6 +183,10 @@ export class DocHyperLinkPopupService extends Disposable {
}
showInfoPopup(info: ILinkInfo, options?: { pinned?: boolean }): IDisposable | null | undefined {
const doc = this._univerInstanceService.getUnit(info.unitId, UniverInstanceType.UNIVER_DOC);
if (!(doc instanceof DocumentDataModel) || getEditorRuntimeConfig(doc)?.customHyperLinkUI) {
return;
}
this.cancelScheduledHideInfoPopup();
if (this._infoPopupSuppressed) {
return;
@@ -192,10 +205,6 @@ export class DocHyperLinkPopupService extends Disposable {
this._infoPopup.dispose();
this._infoPopup = null;
}
const doc = this._univerInstanceService.getUnit(unitId, UniverInstanceType.UNIVER_DOC);
if (!(doc instanceof DocumentDataModel)) {
return;
}
this._infoPopupPinned = options?.pinned ?? false;
this._showingLink$.next({ unitId, linkId, segmentId, segmentPage, startIndex, endIndex });
+1
View File
@@ -266,6 +266,7 @@ export { DocsRenderService } from './services/docs-render.service';
export { Editor } from './services/editor/editor';
export { EditorService, IEditorService } from './services/editor/editor-manager.service';
export type { IEditorRuntimeConfig } from './services/editor/editor-runtime-config';
export { getEditorRuntimeConfig, registerEditorRuntimeConfig } from './services/editor/editor-runtime-config';
export { DocFloatMenuService } from './services/float-menu.service';
export {
isInSameTableCell,
@@ -23,6 +23,8 @@ export interface IEditorRuntimeConfig {
layout?: IDocumentLayoutPresentation;
inheritParagraphStartStyle?: boolean;
disableSelectionAutoScroll?: boolean;
/** The host editor provides its own hyperlink menus and pointer interaction. */
customHyperLinkUI?: boolean;
}
const configs = new WeakMap<DocumentDataModel, { config: IEditorRuntimeConfig }>();