snapshot of https://github.com/dream-num/univer.git (dev, 2026-09-16; blobs > 131072B omitted)
Some checks failed
📦 Build / build (push) Has been cancelled
🧪 CI Test / test shard (1/4) (push) Has been cancelled
🧪 CI Test / test shard (2/4) (push) Has been cancelled
🧪 CI Test / test shard (3/4) (push) Has been cancelled
🧪 CI Test / test shard (4/4) (push) Has been cancelled
🧪 CI Test / test (push) Has been cancelled
🧪 CI Test / eslint (push) Has been cancelled
🧪 CI Test / typecheck (push) Has been cancelled
🔫 Sync to Pro / dispatch-sync (push) Has been cancelled
🔮 Sync Mirror / sync (push) Has been cancelled

This commit is contained in:
upstream-mirror
2026-09-16 05:09:45 +05:00
commit bcf6ef63ad
7118 changed files with 1236854 additions and 0 deletions

View File

@@ -0,0 +1,45 @@
# @univerjs/docs-hyper-link-ui
[![npm version](https://img.shields.io/npm/v/@univerjs/docs-hyper-link-ui?style=flat-square)](https://npmjs.com/package/@univerjs/docs-hyper-link-ui)
[![license](https://img.shields.io/npm/l/@univerjs/docs-hyper-link-ui?style=flat-square)](https://npmjs.com/package/@univerjs/docs-hyper-link-ui)
[![downloads](https://img.shields.io/npm/dm/@univerjs/docs-hyper-link-ui?style=flat-square)](https://npmjs.com/package/@univerjs/docs-hyper-link-ui)
`@univerjs/docs-hyper-link-ui` adds the UI for creating and editing hyperlinks in Univer Docs.
## Package Overview
| Package | UMD global | CSS | Locales | Facade entry |
| --- | --- | :---: | :---: | :---: |
| `@univerjs/docs-hyper-link-ui` | `UniverDocsHyperLinkUi` | Yes | Yes | No |
## Installation
```sh
pnpm add @univerjs/docs-hyper-link-ui
# or
npm install @univerjs/docs-hyper-link-ui
```
Keep all `@univerjs/*` packages on the same version.
## Usage
```ts
import '@univerjs/docs-hyper-link-ui/lib/index.css';
import EnUS from '@univerjs/docs-hyper-link-ui/locale/en-US';
import { UniverDocsHyperLinkUIPlugin } from '@univerjs/docs-hyper-link-ui';
univer.registerPlugin(UniverDocsHyperLinkUIPlugin);
// Merge EnUS into your Univer locale map when this package contributes UI text.
```
## Integration Notes
Register this package after the Docs, Docs UI, and Docs hyperlink core packages.
## Resources
- [Documentation](https://docs.univer.ai)
- [NPM package](https://npmjs.com/package/@univerjs/docs-hyper-link-ui)
- [GitHub repository](https://github.com/dream-num/univer)

View File

@@ -0,0 +1,96 @@
{
"name": "@univerjs/docs-hyper-link-ui",
"version": "1.0.0-rc.0",
"private": false,
"description": "Hyperlink editing UI for Univer Docs.",
"author": "DreamNum Co., Ltd. <developer@univer.ai>",
"license": "Apache-2.0",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/univer"
},
"homepage": "https://univer.ai",
"repository": {
"type": "git",
"url": "https://github.com/dream-num/univer"
},
"bugs": {
"url": "https://github.com/dream-num/univer/issues"
},
"keywords": [
"univer",
"docs",
"hyperlink",
"link",
"ui"
],
"exports": {
".": "./src/index.ts",
"./*": "./src/*",
"./locale/*": "./src/locale/*.ts"
},
"main": "./src/index.ts",
"types": "./lib/types/index.d.ts",
"publishConfig": {
"access": "public",
"main": "./lib/es/index.js",
"module": "./lib/es/index.js",
"exports": {
".": {
"import": "./lib/es/index.js",
"require": "./lib/cjs/index.js",
"types": "./lib/types/index.d.ts"
},
"./*": {
"import": "./lib/es/*",
"require": "./lib/cjs/*",
"types": "./lib/types/index.d.ts"
},
"./locale/*": {
"import": "./lib/es/locale/*.js",
"require": "./lib/cjs/locale/*.js",
"types": "./lib/types/locale/*.d.ts"
},
"./lib/*": "./lib/*"
}
},
"directories": {
"lib": "lib"
},
"files": [
"lib"
],
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"coverage": "vitest run --coverage",
"typecheck": "tsc --noEmit",
"build:bundle": "univer-cli build",
"build:types": "tsc -p tsconfig.node.json",
"build": "pnpm run build:bundle && pnpm run build:types"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc",
"rxjs": ">=7.0.0"
},
"dependencies": {
"@univerjs/core": "workspace:*",
"@univerjs/design": "workspace:*",
"@univerjs/docs": "workspace:*",
"@univerjs/docs-hyper-link": "workspace:*",
"@univerjs/docs-ui": "workspace:*",
"@univerjs/engine-render": "workspace:*",
"@univerjs/icons": "1.38.0",
"@univerjs/protocol": "workspace:*",
"@univerjs/ui": "workspace:*"
},
"devDependencies": {
"@univerjs-infra/shared": "workspace:*",
"postcss": "^8.5.28",
"react": "18.3.1",
"rxjs": "^7.8.2",
"tailwindcss": "3.4.18",
"typescript": "^6.0.3",
"vitest": "^5.0.0"
}
}

View File

@@ -0,0 +1 @@
export { default } from '@univerjs-infra/shared/postcss';

View File

@@ -0,0 +1,192 @@
/**
* 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.
*/
/* eslint-disable ts/no-explicit-any */
import type { Dependency, DocumentDataModel, IDocumentData, Nullable } from '@univerjs/core';
import type { DocumentSkeleton, IRenderContext, IRenderModule } from '@univerjs/engine-render';
import {
BooleanNumber,
DisposableCollection,
DOCS_NORMAL_EDITOR_UNIT_ID_KEY,
ILogService,
Inject,
Injector,
IUndoRedoService,
IUniverInstanceService,
LogLevel,
Plugin,
RxDisposable,
Univer,
UniverInstanceType,
} from '@univerjs/core';
import { DocSelectionManagerService, DocSkeletonManagerService, DocStateChangeManagerService, DocStateEmitService } from '@univerjs/docs';
import { DocIMEInputManagerService, DocSelectionRenderService } from '@univerjs/docs-ui';
import { DocumentViewModel, IRenderManagerService, RenderManagerService } from '@univerjs/engine-render';
import { BehaviorSubject, takeUntil } from 'rxjs';
const DEFAULT_DOC_DATA: IDocumentData = {
id: 'test-doc',
body: {
dataStream: 'Hello world\r\n',
textRuns: [{
st: 0,
ed: 11,
ts: {
bl: BooleanNumber.FALSE,
},
}],
paragraphs: [{ startIndex: 11, paragraphId: 'para_hyper_link_test' }],
sectionBreaks: [],
customBlocks: [],
},
documentStyle: {
pageSize: {
width: 594.3,
height: 840.51,
},
marginTop: 72,
marginBottom: 72,
marginRight: 90,
marginLeft: 90,
},
};
export function createDocUiTestBed(docData?: IDocumentData, dependencies?: Dependency[]) {
const univer = new Univer();
const injector = univer.__getInjector();
const get = injector.get.bind(injector);
class TestPlugin extends Plugin {
static override pluginName = 'test-plugin';
constructor(
_config: undefined,
@Inject(Injector) override readonly _injector: Injector
) {
super();
}
override onStarting(): void {
this._injector.get(IUndoRedoService);
if (!dependencies?.some((dependency) => Array.isArray(dependency) && dependency[0] === IRenderManagerService)) {
this._injector.add([IRenderManagerService, { useClass: RenderManagerService }]);
}
this._injector.add([DocSelectionManagerService]);
this._injector.add([DocStateEmitService]);
this._injector.add([DocStateChangeManagerService]);
this._injector.add([DocIMEInputManagerService]);
this._injector.add([DocSelectionRenderService]);
dependencies?.forEach((dependency) => this._injector.add(dependency));
}
override onReady(): void {
this._injector.get(DocStateChangeManagerService);
}
}
univer.registerPlugin(TestPlugin);
const doc = univer.createUnit<IDocumentData, DocumentDataModel>(UniverInstanceType.UNIVER_DOC, docData || DEFAULT_DOC_DATA);
const univerInstanceService = get(IUniverInstanceService);
const fakeDocSkeletonManager = new MockDocSkeletonManagerService({
unit: doc,
unitId: doc.getUnitId(),
type: UniverInstanceType.UNIVER_DOC,
engine: null as any,
scene: null as any,
mainComponent: null as any,
components: null as any,
isMainScene: true,
activated$: new BehaviorSubject(true),
activate: () => {},
deactivate: () => {},
}, univerInstanceService);
injector.add([DocSkeletonManagerService, { useValue: fakeDocSkeletonManager as unknown as DocSkeletonManagerService }]);
const renderManagerService = get(IRenderManagerService);
if (renderManagerService.getRenderUnitById(doc.getUnitId()) == null) {
renderManagerService.addRender(doc.getUnitId(), {
unitId: doc.getUnitId(),
type: UniverInstanceType.UNIVER_DOC,
engine: new DisposableCollection() as any,
scene: new DisposableCollection() as any,
mainComponent: null as any,
components: new Map(),
isMainScene: true,
activated$: new BehaviorSubject(true),
with: injector.get.bind(injector),
activate: () => {},
deactivate: () => {},
isDisposed: () => false,
});
}
univerInstanceService.focusUnit(doc.getUnitId());
get(ILogService).setLogLevel(LogLevel.SILENT);
return {
univer,
injector,
get,
doc,
};
}
class MockDocSkeletonManagerService extends RxDisposable implements IRenderModule {
private _docViewModel!: DocumentViewModel;
private readonly _currentSkeleton$ = new BehaviorSubject<Nullable<DocumentSkeleton>>(null);
readonly currentSkeleton$ = this._currentSkeleton$.asObservable();
private readonly _currentSkeletonBefore$ = new BehaviorSubject<Nullable<DocumentSkeleton>>(null);
readonly currentSkeletonBefore$ = this._currentSkeletonBefore$.asObservable();
constructor(
private readonly _context: IRenderContext<DocumentDataModel>,
@IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService
) {
super();
this._update();
this._univerInstanceService.getCurrentTypeOfUnit$<DocumentDataModel>(UniverInstanceType.UNIVER_DOC)
.pipe(takeUntil(this.dispose$))
.subscribe((documentModel) => {
if (documentModel?.getUnitId() === this._context.unitId) {
this._update();
}
});
}
private _update() {
const documentDataModel = this._context.unit;
const unitId = this._context.unitId;
if (documentDataModel.getBody() == null) {
return;
}
if (this._docViewModel && unitId === DOCS_NORMAL_EDITOR_UNIT_ID_KEY) {
this._docViewModel.reset(documentDataModel);
} else if (!this._docViewModel) {
this._docViewModel = new DocumentViewModel(documentDataModel);
}
}
getSkeleton(): DocumentSkeleton {
throw new Error('[MockDocSkeletonManagerService]: cannot access to doc skeleton in unit tests!');
}
getViewModel(): DocumentViewModel {
return this._docViewModel;
}
}

View File

@@ -0,0 +1,268 @@
/**
* 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 { DocumentDataModel, ICommand, IDisposable, IDocumentData, Injector } from '@univerjs/core';
import {
awaitTime,
CustomRangeType,
ICommandService,
IUniverInstanceService,
toDisposable,
UniverInstanceType,
} from '@univerjs/core';
import { DocSelectionManagerService, RichTextEditingMutation, SetTextSelectionsOperation } from '@univerjs/docs';
import { DocCanvasPopManagerService } from '@univerjs/docs-ui';
import { IDialogService } from '@univerjs/ui';
import { of } from 'rxjs';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { AddDocHyperLinkCommand } from '../commands/commands/add-link.command';
import { DeleteDocHyperLinkCommand } from '../commands/commands/delete-link.command';
import { UpdateDocHyperLinkCommand } from '../commands/commands/update-link.command';
import { ClickDocHyperLinkOperation } from '../commands/operations/popup.operation';
import { DocHyperLinkSelectionController } from '../controllers/doc-hyper-link-selection.controller';
import { DocHyperLinkPopupService } from '../services/hyper-link-popup.service';
import { createDocUiTestBed } from './create-doc-ui-test-bed';
function createDocData(): IDocumentData {
return {
id: 'test-doc',
body: {
dataStream: 'Hello world\r\n',
customRanges: [{
startIndex: 6,
endIndex: 10,
rangeId: 'link-1',
rangeType: CustomRangeType.HYPERLINK,
properties: {
url: 'https://before.invalid',
},
}, {
startIndex: 0,
endIndex: 1,
rangeId: 'custom-1',
rangeType: CustomRangeType.CUSTOM,
}],
},
documentStyle: {
pageSize: {
width: 594.3,
height: 840.51,
},
marginTop: 72,
marginBottom: 72,
marginRight: 90,
marginLeft: 90,
},
};
}
function getBody(get: Injector['get']) {
return get(IUniverInstanceService)
.getUnit<DocumentDataModel>('test-doc', UniverInstanceType.UNIVER_DOC)
?.getBody();
}
function createPopupManagerStub() {
const disposables: IDisposable[] = [];
return {
disposables,
attachPopupToRange: vi.fn(() => {
const disposable = {
dispose: vi.fn(),
};
disposables.push(disposable);
return disposable;
}),
};
}
describe('docs-hyper-link-ui integration', () => {
let univer: ReturnType<typeof createDocUiTestBed>['univer'];
let get: Injector['get'];
let injector: Injector;
let commandService: ICommandService;
beforeEach(() => {
const testBed = createDocUiTestBed(createDocData());
univer = testBed.univer;
get = testBed.get;
injector = testBed.injector;
commandService = get(ICommandService);
commandService.registerCommand(AddDocHyperLinkCommand);
commandService.registerCommand(UpdateDocHyperLinkCommand);
commandService.registerCommand(DeleteDocHyperLinkCommand);
commandService.registerCommand(ClickDocHyperLinkOperation);
commandService.registerCommand(SetTextSelectionsOperation);
commandService.registerCommand(RichTextEditingMutation as unknown as ICommand);
const selectionManager = get(DocSelectionManagerService);
selectionManager.__TEST_ONLY_setCurrentSelection({
unitId: 'test-doc',
subUnitId: 'test-doc',
});
});
afterEach(() => {
univer.dispose();
vi.restoreAllMocks();
});
it('adds a hyperlink through the real command pipeline', async () => {
const selectionManager = get(DocSelectionManagerService);
selectionManager.__TEST_ONLY_add([{
startOffset: 2,
endOffset: 5,
collapsed: false,
isActive: true,
segmentId: '',
style: null as never,
}]);
expect(await commandService.executeCommand(AddDocHyperLinkCommand.id, {
unitId: 'test-doc',
payload: 'https://added.invalid',
})).toBeTruthy();
await awaitTime(0);
const addedLink = getBody(get)?.customRanges?.find((range) => range.properties?.url === 'https://added.invalid');
expect(addedLink).toBeDefined();
expect(addedLink?.rangeType).toBe(CustomRangeType.HYPERLINK);
});
it('updates hyperlink text and payload through the real command pipeline', async () => {
const selectionManager = get(DocSelectionManagerService);
selectionManager.__TEST_ONLY_add([{
startOffset: 6,
endOffset: 11,
collapsed: false,
isActive: true,
segmentId: '',
style: null as never,
}]);
expect(await commandService.executeCommand(UpdateDocHyperLinkCommand.id, {
unitId: 'test-doc',
linkId: 'link-1',
payload: 'https://after.invalid',
label: 'planet',
segmentId: '',
})).toBeTruthy();
await awaitTime(0);
expect(getBody(get)?.dataStream).toBe('Hello planet\r\n');
expect(getBody(get)?.customRanges?.find((range) => range.rangeId === 'link-1')?.properties).toEqual({
url: 'https://after.invalid',
});
});
it('deletes a hyperlink range while keeping the text content', async () => {
expect(getBody(get)?.dataStream).toBe('Hello world\r\n');
expect(await commandService.executeCommand(DeleteDocHyperLinkCommand.id, {
unitId: 'test-doc',
linkId: 'link-1',
})).toBeTruthy();
await awaitTime(0);
expect(getBody(get)?.dataStream).toBe('Hello world\r\n');
expect(getBody(get)?.customRanges?.some((range) => range.rangeId === 'link-1')).toBe(false);
});
it('shows and hides the info popup when the executed selection enters and leaves a hyperlink', async () => {
const popupManagerStub = createPopupManagerStub();
injector.add([DocCanvasPopManagerService, { useValue: popupManagerStub as unknown as DocCanvasPopManagerService }]);
injector.add([IDialogService, {
useValue: {
close: () => {},
closeAll: () => {},
getDialogs$: () => of([]),
open: () => toDisposable(() => {}),
},
}]);
injector.add([DocHyperLinkPopupService]);
injector.add([DocHyperLinkSelectionController]);
const popupService = injector.get(DocHyperLinkPopupService);
injector.get(DocHyperLinkSelectionController);
expect(await commandService.executeCommand(SetTextSelectionsOperation.id, {
unitId: 'test-doc',
subUnitId: 'test-doc',
segmentId: '',
isEditing: false,
style: null,
ranges: [{
startOffset: 7,
endOffset: 7,
collapsed: true,
segmentId: '',
}],
})).toBe(true);
expect(popupService.showing).toMatchObject({
unitId: 'test-doc',
linkId: 'link-1',
segmentId: '',
startIndex: 6,
endIndex: 10,
});
expect(popupManagerStub.attachPopupToRange).toHaveBeenCalledWith({
collapsed: false,
startOffset: 6,
endOffset: 11,
segmentId: '',
segmentPage: undefined,
}, expect.objectContaining({
componentKey: 'univer.doc.link-info-popup',
offset: [0, 10],
}), 'test-doc');
expect(await commandService.executeCommand(SetTextSelectionsOperation.id, {
unitId: 'test-doc',
subUnitId: 'test-doc',
segmentId: '',
isEditing: false,
style: null,
ranges: [{
startOffset: 1,
endOffset: 1,
collapsed: true,
segmentId: '',
}],
})).toBe(true);
expect(popupService.showing).toBeNull();
expect(popupManagerStub.disposables[0]?.dispose).toHaveBeenCalled();
});
it('opens the hyperlink URL when the click operation is executed', async () => {
const openSpy = vi.fn();
vi.stubGlobal('window', { open: openSpy });
expect(await commandService.executeCommand(ClickDocHyperLinkOperation.id, {
unitId: 'test-doc',
linkId: 'link-1',
segmentId: '',
})).toBe(true);
expect(openSpy).toHaveBeenCalledWith('https://before.invalid', '_blank', 'noopener noreferrer');
});
});

View File

@@ -0,0 +1,199 @@
/**
* 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 { DocumentDataModel, ICommand, IDocumentData } from '@univerjs/core';
import { CustomRangeType, ICommandService, IUniverInstanceService, Univer, UniverInstanceType } from '@univerjs/core';
import { DocSelectionManagerService, DocStateEmitService, RichTextEditingMutation } from '@univerjs/docs';
import { IRenderManagerService, RenderManagerService } from '@univerjs/engine-render';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { AddDocHyperLinkCommand } from '../add-link.command';
import { DeleteDocHyperLinkCommand } from '../delete-link.command';
import { UpdateDocHyperLinkCommand } from '../update-link.command';
const unitId = 'hyper-link-doc';
function createDocData(): IDocumentData {
return {
id: unitId,
body: {
dataStream: 'Hello world\r\n',
customRanges: [{
startIndex: 6,
endIndex: 10,
rangeId: 'existing-link',
rangeType: CustomRangeType.HYPERLINK,
properties: {
url: 'https://before.invalid',
},
}],
},
documentStyle: {
pageSize: {
width: 594.3,
height: 840.51,
},
marginTop: 72,
marginBottom: 72,
marginRight: 90,
marginLeft: 90,
},
};
}
describe('doc hyperlink commands', () => {
let univer: Univer;
let commandService: ICommandService;
function getDocBody() {
return univer.__getInjector()
.get(IUniverInstanceService)
.getUnit<DocumentDataModel>(unitId, UniverInstanceType.UNIVER_DOC)
?.getBody();
}
beforeEach(() => {
univer = new Univer();
const injector = univer.__getInjector();
injector.add([DocSelectionManagerService]);
injector.add([DocStateEmitService]);
injector.add([IRenderManagerService, { useClass: RenderManagerService }]);
univer.createUnit(UniverInstanceType.UNIVER_DOC, createDocData());
injector.get(IUniverInstanceService).focusUnit(unitId);
commandService = injector.get(ICommandService);
commandService.registerCommand(AddDocHyperLinkCommand);
commandService.registerCommand(DeleteDocHyperLinkCommand);
commandService.registerCommand(UpdateDocHyperLinkCommand);
commandService.registerCommand(RichTextEditingMutation as unknown as ICommand);
});
afterEach(() => {
univer.dispose();
});
it('adds a hyperlink to the selected document text', async () => {
const result = await commandService.executeCommand(AddDocHyperLinkCommand.id, {
unitId,
payload: 'https://added.invalid',
selections: [{
startOffset: 0,
endOffset: 5,
collapsed: false,
segmentId: '',
}],
});
let addedLink;
for (const range of getDocBody()?.customRanges ?? []) {
if (range.properties?.url === 'https://added.invalid') {
addedLink = range;
break;
}
}
expect(result).toBeTruthy();
expect(addedLink).toMatchObject({
startIndex: 0,
endIndex: 4,
rangeType: CustomRangeType.HYPERLINK,
properties: {
url: 'https://added.invalid',
},
});
});
it('removes a hyperlink while keeping the document text', async () => {
const result = await commandService.executeCommand(DeleteDocHyperLinkCommand.id, {
unitId,
linkId: 'existing-link',
});
let removedLinkStillExists = false;
for (const range of getDocBody()?.customRanges ?? []) {
if (range.rangeId === 'existing-link') {
removedLinkStillExists = true;
break;
}
}
expect(result).toBeTruthy();
expect(getDocBody()?.dataStream).toBe('Hello world\r\n');
expect(removedLinkStillExists).toBe(false);
});
it('does not remove document hyperlinks when delete parameters are missing or target a missing link', async () => {
expect(await commandService.executeCommand(DeleteDocHyperLinkCommand.id)).toBe(false);
expect(await commandService.executeCommand(DeleteDocHyperLinkCommand.id, {
unitId,
linkId: 'missing-link',
})).toBe(false);
let existingLinkStillExists = false;
for (const range of getDocBody()?.customRanges ?? []) {
if (range.rangeId === 'existing-link') {
existingLinkStillExists = true;
break;
}
}
expect(existingLinkStillExists).toBe(true);
expect(getDocBody()?.dataStream).toBe('Hello world\r\n');
});
it('updates a hyperlink label and target for the selected document text', async () => {
const selectionManager = univer.__getInjector().get(DocSelectionManagerService);
selectionManager.__TEST_ONLY_setCurrentSelection({
unitId,
subUnitId: unitId,
});
selectionManager.__TEST_ONLY_add([{
startOffset: 6,
endOffset: 11,
collapsed: false,
isActive: true,
segmentId: '',
style: null as never,
}]);
const result = await commandService.executeCommand(UpdateDocHyperLinkCommand.id, {
unitId,
linkId: 'existing-link',
payload: 'https://after.invalid',
label: 'Univer',
segmentId: '',
});
let updatedLink;
for (const range of getDocBody()?.customRanges ?? []) {
if (range.rangeId === 'existing-link') {
updatedLink = range;
break;
}
}
expect(result).toBeTruthy();
expect(getDocBody()?.dataStream).toBe('Hello Univer\r\n');
expect(updatedLink).toMatchObject({
startIndex: 6,
endIndex: 11,
rangeType: CustomRangeType.HYPERLINK,
properties: {
url: 'https://after.invalid',
},
});
});
});

View File

@@ -0,0 +1,58 @@
/**
* 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 { ICommand, ITextRangeParam } from '@univerjs/core';
import { CommandType, CustomRangeType, generateRandomId, ICommandService } from '@univerjs/core';
import { addCustomRangeBySelectionFactory } from '@univerjs/docs';
import { DocHyperLinkCommandId } from '@univerjs/docs-hyper-link';
export interface IAddDocHyperLinkCommandParams {
payload: string;
unitId: string;
selections?: ITextRangeParam[];
}
export const AddDocHyperLinkCommand: ICommand<IAddDocHyperLinkCommandParams> = {
type: CommandType.COMMAND,
id: DocHyperLinkCommandId.Add,
async handler(accessor, params) {
if (!params) {
return false;
}
const { payload, unitId, selections } = params;
const commandService = accessor.get(ICommandService);
const id = generateRandomId();
const doMutation = addCustomRangeBySelectionFactory(
accessor,
{
rangeId: id,
rangeType: CustomRangeType.HYPERLINK,
properties: {
url: payload,
},
unitId,
selections,
}
);
if (doMutation) {
return commandService.syncExecuteCommand(doMutation.id, doMutation.params);
}
return false;
},
};

View File

@@ -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 { ICommand } from '@univerjs/core';
import { CommandType, ICommandService } from '@univerjs/core';
import { deleteCustomRangeFactory } from '@univerjs/docs';
import { DocHyperLinkCommandId } from '@univerjs/docs-hyper-link';
export interface IDeleteDocHyperLinkMutationParams {
unitId: string;
linkId: string;
segmentId?: string;
}
export const DeleteDocHyperLinkCommand: ICommand<IDeleteDocHyperLinkMutationParams> = {
type: CommandType.COMMAND,
id: DocHyperLinkCommandId.Delete,
async handler(accessor, params) {
if (!params) {
return false;
}
const { unitId, linkId, segmentId } = params;
const commandService = accessor.get(ICommandService);
const doMutation = deleteCustomRangeFactory(accessor, { unitId, rangeId: linkId, segmentId });
if (!doMutation) {
return false;
}
return await commandService.syncExecuteCommand(doMutation.id, doMutation.params);
},
};

View File

@@ -0,0 +1,86 @@
/**
* 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 { DocumentDataModel, ICommand } from '@univerjs/core';
import { CommandType, CustomRangeType, getBodySlice, ICommandService, IUniverInstanceService, UniverInstanceType } from '@univerjs/core';
import { DocSelectionManagerService, replaceSelectionFactory } from '@univerjs/docs';
import { DocHyperLinkCommandId } from '@univerjs/docs-hyper-link';
export interface IUpdateDocHyperLinkCommandParams {
unitId: string;
linkId: string;
payload: string;
label: string;
segmentId: string;
}
export const UpdateDocHyperLinkCommand: ICommand<IUpdateDocHyperLinkCommandParams> = {
id: DocHyperLinkCommandId.Update,
type: CommandType.COMMAND,
handler(accessor, params) {
if (!params) {
return false;
}
const { unitId, payload, segmentId, linkId } = params;
const commandService = accessor.get(ICommandService);
const univerInstanceService = accessor.get(IUniverInstanceService);
const docSelectionManagerService = accessor.get(DocSelectionManagerService);
const currentSelection = docSelectionManagerService.getActiveTextRange();
const doc = univerInstanceService.getUnit<DocumentDataModel>(unitId, UniverInstanceType.UNIVER_DOC);
if (!currentSelection || !doc) {
return false;
}
const oldBody = doc.getSelfOrHeaderFooterModel(segmentId)?.getBody();
if (!oldBody) {
return false;
}
const textRun = getBodySlice(oldBody, currentSelection.startOffset!, currentSelection.endOffset!).textRuns?.[0];
if (textRun) {
textRun.ed = params.label.length + 1;
}
const replaceSelection = replaceSelectionFactory(accessor, {
unitId,
body: {
dataStream: `${params.label}`,
customRanges: [{
rangeId: linkId,
rangeType: CustomRangeType.HYPERLINK,
startIndex: 0,
endIndex: params.label.length + 1,
properties: {
url: payload,
},
}],
textRuns: textRun ? [textRun] : undefined,
},
selection: {
startOffset: currentSelection.startOffset!,
endOffset: currentSelection.endOffset!,
collapsed: false,
segmentId,
},
});
if (!replaceSelection) {
return false;
}
return commandService.syncExecuteCommand(replaceSelection.id, replaceSelection.params);
},
};

View File

@@ -0,0 +1,172 @@
/**
* 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 { IDocumentData } from '@univerjs/core';
import { CustomRangeType, ICommandService, toDisposable, Univer, UniverInstanceType } from '@univerjs/core';
import { DocSelectionManagerService } from '@univerjs/docs';
import { DocCanvasPopManagerService } from '@univerjs/docs-ui';
import { IRenderManagerService, RenderManagerService } from '@univerjs/engine-render';
import { IDialogService } from '@univerjs/ui';
import { of } from 'rxjs';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { DocHyperLinkPopupService } from '../../../services/hyper-link-popup.service';
import {
ClickDocHyperLinkOperation,
ShowDocHyperLinkEditPopupOperation,
ToggleDocHyperLinkInfoPopupOperation,
} from '../popup.operation';
const unitId = 'hyper-link-popup-doc';
function createDocData(): IDocumentData {
return {
id: unitId,
body: {
dataStream: 'Hello world\r\n',
customRanges: [{
rangeId: 'unsafe-link',
rangeType: CustomRangeType.HYPERLINK,
startIndex: 0,
endIndex: 4,
properties: {
url: 'javascript:alert(1)',
},
}],
},
documentStyle: {
pageSize: {
width: 594.3,
height: 840.51,
},
marginTop: 72,
marginBottom: 72,
marginRight: 90,
marginLeft: 90,
},
};
}
class TestDocCanvasPopManagerService {
attachPopupToRange() {
return toDisposable(() => {});
}
}
describe('doc hyperlink popup operations', () => {
let univer: Univer;
let commandService: ICommandService;
let selectionManager: DocSelectionManagerService;
let popupService: DocHyperLinkPopupService;
beforeEach(() => {
univer = new Univer();
const injector = univer.__getInjector();
injector.add([DocSelectionManagerService]);
injector.add([DocCanvasPopManagerService, { useClass: TestDocCanvasPopManagerService as never }]);
injector.add([IRenderManagerService, { useClass: RenderManagerService }]);
injector.add([IDialogService, {
useValue: {
close: () => {},
closeAll: () => {},
getDialogs$: () => of([]),
open: () => toDisposable(() => {}),
},
}]);
injector.add([DocHyperLinkPopupService]);
univer.createUnit(UniverInstanceType.UNIVER_DOC, createDocData());
commandService = injector.get(ICommandService);
commandService.registerCommand(ShowDocHyperLinkEditPopupOperation);
commandService.registerCommand(ToggleDocHyperLinkInfoPopupOperation);
commandService.registerCommand(ClickDocHyperLinkOperation);
selectionManager = injector.get(DocSelectionManagerService);
popupService = injector.get(DocHyperLinkPopupService);
selectionManager.__TEST_ONLY_setCurrentSelection({
unitId,
subUnitId: unitId,
});
});
afterEach(() => {
univer.dispose();
});
it('opens the hyperlink editor for a selected document range', async () => {
selectionManager.__TEST_ONLY_add([{
startOffset: 0,
endOffset: 5,
collapsed: false,
isActive: true,
segmentId: '',
style: null as never,
}]);
const result = await commandService.executeCommand(ShowDocHyperLinkEditPopupOperation.id);
expect(result).toBe(true);
expect(popupService.editing).toBeNull();
});
it('keeps the hyperlink editor closed when there is no selected text', async () => {
const result = await commandService.executeCommand(ShowDocHyperLinkEditPopupOperation.id);
expect(result).toBe(false);
expect(popupService.editing).toBeNull();
});
it('opens the hyperlink editor for an existing link and stores its range context', async () => {
const link = {
unitId,
linkId: 'link-1',
startIndex: 6,
endIndex: 10,
segmentId: '',
};
const result = await commandService.executeCommand(ShowDocHyperLinkEditPopupOperation.id, { link });
expect(result).toBe(true);
expect(popupService.editing).toEqual(link);
});
it('shows and hides hyperlink information for the selected link', async () => {
const link = {
unitId,
linkId: 'link-1',
startIndex: 6,
endIndex: 10,
segmentId: '',
};
const showResult = await commandService.executeCommand(ToggleDocHyperLinkInfoPopupOperation.id, link);
expect(showResult).toBe(true);
expect(popupService.showing).toEqual(link);
const hideResult = await commandService.executeCommand(ToggleDocHyperLinkInfoPopupOperation.id);
expect(hideResult).toBe(true);
expect(popupService.showing).toBeNull();
});
it('does not open unsafe hyperlink URLs', async () => {
const result = await commandService.executeCommand(ClickDocHyperLinkOperation.id, {
unitId,
linkId: 'unsafe-link',
segmentId: '',
});
expect(result).toBe(false);
});
});

View File

@@ -0,0 +1,113 @@
/**
* 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 { DocumentDataModel, IAccessor, ICommand } from '@univerjs/core';
import { CommandType, CustomRangeType, isSafeUrl, IUniverInstanceService, UniverInstanceType } from '@univerjs/core';
import { DocSelectionManagerService } from '@univerjs/docs';
import { DocHyperLinkPopupService } from '../../services/hyper-link-popup.service';
export const shouldDisableAddLink = (accessor: IAccessor) => {
const textSelectionService = accessor.get(DocSelectionManagerService);
const univerInstanceService = accessor.get(IUniverInstanceService);
const textRanges = textSelectionService.getTextRanges();
if (!textRanges?.length) {
return true;
}
const activeRange = textRanges[0];
const doc = univerInstanceService.getCurrentUnitOfType<DocumentDataModel>(UniverInstanceType.UNIVER_DOC);
if (!doc || !activeRange || activeRange.collapsed) {
return true;
}
return false;
};
export interface IShowDocHyperLinkEditPopupOperationParams {
link?: {
unitId: string;
linkId: string;
segmentId?: string;
segmentPage?: number;
startIndex: number;
endIndex: number;
};
}
export const ShowDocHyperLinkEditPopupOperation: ICommand<IShowDocHyperLinkEditPopupOperationParams> = {
type: CommandType.OPERATION,
id: 'doc.operation.show-hyper-link-edit-popup',
handler(accessor, params) {
const linkInfo = params?.link;
const univerInstanceService = accessor.get(IUniverInstanceService);
if (shouldDisableAddLink(accessor) && !linkInfo) {
return false;
}
const hyperLinkService = accessor.get(DocHyperLinkPopupService);
const unitId = linkInfo?.unitId || univerInstanceService.getCurrentUnitOfType(UniverInstanceType.UNIVER_DOC)?.getUnitId();
if (!unitId) {
return false;
}
hyperLinkService.showEditPopup(unitId, linkInfo ?? null);
return true;
},
};
export interface IShowDocHyperLinkInfoPopupOperationParams {
linkId: string;
segmentId?: string;
unitId: string;
segmentPage?: number;
startIndex: number;
endIndex: number;
}
export const ToggleDocHyperLinkInfoPopupOperation: ICommand<IShowDocHyperLinkInfoPopupOperationParams> = {
type: CommandType.OPERATION,
id: 'doc.operation.toggle-hyper-link-info-popup',
handler(accessor, params) {
const hyperLinkService = accessor.get(DocHyperLinkPopupService);
if (!params) {
hyperLinkService.hideInfoPopup();
return true;
}
hyperLinkService.showInfoPopup(params);
return true;
},
};
export const ClickDocHyperLinkOperation: ICommand<{ unitId: string; linkId: string; segmentId?: string }> = {
type: CommandType.OPERATION,
id: 'doc.operation.click-hyper-link',
handler(accessor, params) {
if (!params) {
return false;
}
const { unitId, linkId, segmentId } = params;
const univerInstanceService = accessor.get(IUniverInstanceService);
const doc = univerInstanceService.getUnit<DocumentDataModel>(unitId, UniverInstanceType.UNIVER_DOC);
const body = doc?.getSelfOrHeaderFooterModel(segmentId)?.getBody();
const link = body?.customRanges?.find((range) => range.rangeId === linkId && range.rangeType === CustomRangeType.HYPERLINK)?.properties?.url;
if (!isSafeUrl(link)) {
return false;
}
window.open(link, '_blank', 'noopener noreferrer');
return true;
},
};

View File

@@ -0,0 +1,27 @@
/**
* 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 { MenuConfig } from '@univerjs/ui';
export const DOCS_HYPER_LINK_UI_PLUGIN_CONFIG_KEY = 'docs-hyper-link-ui.config';
export const configSymbol = Symbol(DOCS_HYPER_LINK_UI_PLUGIN_CONFIG_KEY);
export interface IUniverDocsHyperLinkUIConfig {
menu?: MenuConfig;
}
export const defaultPluginConfig: IUniverDocsHyperLinkUIConfig = {};

View File

@@ -0,0 +1,159 @@
/**
* 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 { DocumentDataModel, IDocumentData } from '@univerjs/core';
import type { ISetTextSelectionsOperationParams } from '@univerjs/docs';
import type { RenderUnit } from '@univerjs/engine-render';
import { BooleanNumber, CustomRangeType, DocumentFlavor, ICommandService, Univer, UniverInstanceType } from '@univerjs/core';
import { DocLayoutExecutorService, DocSelectionManagerService, DocSkeletonManagerService, SetTextSelectionsOperation } from '@univerjs/docs';
import { DocCanvasPopManagerService } from '@univerjs/docs-ui';
import { CanvasColorService, Documents, ICanvasColorService, IRenderManagerService, RenderManagerService } from '@univerjs/engine-render';
import { CanvasPopupService, DesktopDialogService, ICanvasPopupService, IDialogService, IUIPartsService, UIPartsService } from '@univerjs/ui';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { DocHyperLinkPopupService } from '../../services/hyper-link-popup.service';
import { DocHyperLinkSelectionController } from '../doc-hyper-link-selection.controller';
describe.each([1, 3])('DocHyperLinkSelectionController with a %i-character link', (length) => {
let univer: Univer;
let commands: ICommandService;
let popup: DocHyperLinkPopupService;
const startIndex = 1;
const endIndex = startIndex + length - 1;
beforeEach(() => {
// Only Canvas is a platform stub; commands, selection, document data
// and popup lifecycle use their real services.
const context = new Proxy({
font: '',
webkitBackingStorePixelRatio: 1,
measureText: (text: string) => ({
width: text.length * 8,
actualBoundingBoxAscent: 8,
actualBoundingBoxDescent: 2,
fontBoundingBoxAscent: 8,
fontBoundingBoxDescent: 2,
}),
}, { get: (target, key) => key in target ? Reflect.get(target, key) : () => {} });
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(context as never);
univer = new Univer();
const injector = univer.__getInjector();
injector.add([IRenderManagerService, { useClass: RenderManagerService }]);
injector.add([ICanvasColorService, { useClass: CanvasColorService }]);
injector.add([ICanvasPopupService, { useClass: CanvasPopupService }]);
injector.add([IUIPartsService, { useClass: UIPartsService }]);
injector.add([IDialogService, { useClass: DesktopDialogService }]);
injector.add([DocLayoutExecutorService]);
injector.add([DocSelectionManagerService]);
injector.add([DocCanvasPopManagerService]);
injector.add([DocHyperLinkPopupService]);
injector.add([DocHyperLinkSelectionController]);
const model = univer.createUnit<IDocumentData, DocumentDataModel>(UniverInstanceType.UNIVER_DOC, {
id: 'doc-unit',
body: {
dataStream: 'A目CDZ\r\n',
paragraphs: [{ startIndex: 5, paragraphId: 'paragraph-1' }],
sectionBreaks: [{ startIndex: 6, sectionId: 'body' }],
customRanges: [
{ rangeId: 'link-1', rangeType: CustomRangeType.HYPERLINK, startIndex, endIndex },
{ rangeId: 'link-2', rangeType: CustomRangeType.HYPERLINK, startIndex: endIndex + 1, endIndex: endIndex + 1 },
],
},
documentStyle: {
documentFlavor: DocumentFlavor.TRADITIONAL,
autoHyphenation: BooleanNumber.FALSE,
pageSize: { width: 300, height: 400 },
marginTop: 20,
marginBottom: 20,
marginLeft: 20,
marginRight: 20,
},
});
const render = injector.get(IRenderManagerService).createRender(model.getUnitId()) as RenderUnit;
render.deactivate();
render.engine.resizeBySize(300, 400);
vi.spyOn(render.engine.getCanvasElement()!, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 0, 300, 400));
render.addRenderDependencies([[DocSkeletonManagerService]]);
const documents = new Documents('link-selection-document', render.with(DocSkeletonManagerService).getSkeleton());
render.mainComponent = documents;
render.scene.addObject(documents);
commands = injector.get(ICommandService);
commands.registerCommand(SetTextSelectionsOperation);
popup = injector.get(DocHyperLinkPopupService);
injector.get(DocHyperLinkSelectionController);
});
afterEach(() => {
univer.dispose();
vi.restoreAllMocks();
});
async function select(startOffset: number, endOffset = startOffset): Promise<void> {
const params: ISetTextSelectionsOperationParams = {
unitId: 'doc-unit',
subUnitId: 'doc-unit',
segmentId: '',
isEditing: false,
style: { fill: '', stroke: '', strokeActive: '', strokeWidth: 0 },
ranges: [{ startOffset, endOffset, collapsed: startOffset === endOffset, segmentPage: 0 }],
};
expect(await commands.executeCommand(SetTextSelectionsOperation.id, params)).toBe(true);
}
it.each(['first', 'last', 'trailing'] as const)('keeps details visible at the %s caret boundary', async (boundary) => {
const offsets = { first: startIndex, last: endIndex, trailing: endIndex + 1 };
const info = { unitId: 'doc-unit', linkId: 'link-1', segmentId: '', segmentPage: 0, startIndex, endIndex };
// Pointer events open details before the asynchronous selection operation
// completes. Repeated clicks must not close that same popup.
for (let attempt = 0; attempt < 3; attempt++) {
popup.showInfoPopup(info);
await select(offsets[boundary]);
expect(popup.showing).toEqual(info);
}
});
it.each(['before', 'after'] as const)('closes details for a caret %s the link', async (boundary) => {
await select(startIndex);
expect(popup.showing?.linkId).toBe('link-1');
await select(boundary === 'before' ? startIndex - 1 : endIndex + 2);
expect(popup.showing).toBeNull();
});
it('keeps the clicked adjacent link instead of switching to the preceding link', async () => {
const info = {
unitId: 'doc-unit',
linkId: 'link-2',
segmentId: '',
segmentPage: 0,
startIndex: endIndex + 1,
endIndex: endIndex + 1,
};
popup.showInfoPopup(info);
await select(endIndex + 1);
expect(popup.showing).toEqual(info);
});
it('resolves a fresh caret at an adjacent link using the character at that offset', async () => {
await select(endIndex + 1);
expect(popup.showing?.linkId).toBe('link-2');
});
it('closes details for an expanded selection', async () => {
await select(startIndex);
expect(popup.showing?.linkId).toBe('link-1');
await select(startIndex, endIndex + 1);
expect(popup.showing).toBeNull();
});
});

View File

@@ -0,0 +1,50 @@
/**
* 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 { Disposable, Inject } from '@univerjs/core';
import { LinkIcon } from '@univerjs/icons';
import { ComponentManager, IconManager } from '@univerjs/ui';
import { DocHyperLinkEdit } from '../views/DocHyperLinkEdit';
import { DocLinkPopup } from '../views/DocLinkPopup';
export class ComponentsController extends Disposable {
constructor(
@Inject(ComponentManager) private readonly _componentManager: ComponentManager,
@Inject(IconManager) private readonly _iconManager: IconManager
) {
super();
this._registerComponents();
this._registerIcons();
}
private _registerComponents(): void {
([
[DocHyperLinkEdit.componentKey, DocHyperLinkEdit],
[DocLinkPopup.componentKey, DocLinkPopup],
] as const).forEach(([key, comp]) => {
this.disposeWithMe(
this._componentManager.register(key, comp)
);
});
}
private _registerIcons(): void {
this.disposeWithMe(this._iconManager.register({
LinkIcon,
}));
}
}

View File

@@ -0,0 +1,92 @@
/**
* 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 { DocumentDataModel } from '@univerjs/core';
import type { ISetTextSelectionsOperationParams } from '@univerjs/docs';
import {
CustomRangeType,
Disposable,
ICommandService,
Inject,
IUniverInstanceService,
UniverInstanceType,
} from '@univerjs/core';
import { SetTextSelectionsOperation } from '@univerjs/docs';
import { DocHyperLinkPopupService } from '../services/hyper-link-popup.service';
export class DocHyperLinkSelectionController extends Disposable {
constructor(
@ICommandService private readonly _commandService: ICommandService,
@IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService,
@Inject(DocHyperLinkPopupService) private readonly _docHyperLinkService: DocHyperLinkPopupService
) {
super();
this._initSelectionChange();
}
private _initSelectionChange() {
this.disposeWithMe(
this._commandService.onCommandExecuted((commandInfo) => {
if (commandInfo.id === SetTextSelectionsOperation.id) {
const params = commandInfo.params as ISetTextSelectionsOperationParams;
const { unitId, ranges, segmentId } = params;
const doc = this._univerInstanceService.getUnit<DocumentDataModel>(unitId, UniverInstanceType.UNIVER_DOC);
const primary = ranges[0];
const editing = this._docHyperLinkService.editing;
// Opening the editor selects its own link; that is not a user dismissal.
if (editing?.unitId === unitId &&
(editing.segmentId ?? '') === (segmentId ?? '') &&
primary?.startOffset === editing.startIndex && primary.endOffset === editing.endIndex + 1) {
return;
}
if (primary?.collapsed && doc) {
const { startOffset, endOffset, segmentPage } = primary;
const customRanges = doc.getSelfOrHeaderFooterModel(segmentId)?.getBody()?.customRanges;
const showing = this._docHyperLinkService.showing;
// Pointer-up opens the clicked link before selection completes.
// Preserve that link at its trailing edge without claiming the
// leading edge of an adjacent link for a fresh caret selection.
const trailingLink = showing?.unitId === unitId &&
(showing.segmentId ?? '') === segmentId &&
showing.segmentPage === segmentPage &&
showing.endIndex === endOffset - 1
? customRanges?.find((value) => (
value.rangeId === showing.linkId &&
value.rangeType === CustomRangeType.HYPERLINK &&
value.startIndex <= startOffset &&
value.endIndex === endOffset - 1
))
: undefined;
const customRange = trailingLink ?? customRanges?.find((value) => (
value.rangeType === CustomRangeType.HYPERLINK &&
value.startIndex <= startOffset &&
value.endIndex > endOffset - 1
));
if (customRange) {
this._docHyperLinkService.showInfoPopup({ unitId, linkId: customRange.rangeId, segmentId, segmentPage, startIndex: customRange.startIndex, endIndex: customRange.endIndex });
return;
}
}
this._docHyperLinkService.hideInfoPopup();
this._docHyperLinkService.hideEditPopup();
}
})
);
}
}

View File

@@ -0,0 +1,50 @@
/**
* 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 { Disposable, Inject } from '@univerjs/core';
import { LinkIcon } from '@univerjs/icons';
import { ComponentManager, IconManager } from '@univerjs/ui';
import { MobileDocHyperLinkEdit } from '../../views/MobileDocHyperLinkEdit';
import { MobileDocLinkPopup } from '../../views/MobileDocLinkPopup';
export class MobileComponentsController extends Disposable {
constructor(
@Inject(ComponentManager) private readonly _componentManager: ComponentManager,
@Inject(IconManager) private readonly _iconManager: IconManager
) {
super();
this._registerComponents();
this._registerIcons();
}
private _registerComponents(): void {
([
[MobileDocHyperLinkEdit.componentKey, MobileDocHyperLinkEdit],
[MobileDocLinkPopup.componentKey, MobileDocLinkPopup],
] as const).forEach(([key, comp]) => {
this.disposeWithMe(
this._componentManager.register(key, comp)
);
});
}
private _registerIcons(): void {
this.disposeWithMe(this._iconManager.register({
LinkIcon,
}));
}
}

View File

@@ -0,0 +1,362 @@
/**
* 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 { DocumentDataModel } from '@univerjs/core';
import type { IRenderContext } from '@univerjs/engine-render';
import { CustomRangeType, DOCS_NORMAL_EDITOR_UNIT_ID_KEY, ICommandService, Univer } from '@univerjs/core';
import { DocSelectionManagerService, DocSkeletonManagerService } from '@univerjs/docs';
import { DocEventManagerService } from '@univerjs/docs-ui';
import { config, Subject } from 'rxjs';
import { describe, expect, it, vi } from 'vitest';
import { DeleteDocHyperLinkCommand } from '../../../commands/commands/delete-link.command';
import { ClickDocHyperLinkOperation } from '../../../commands/operations/popup.operation';
import { DocHyperLinkPopupService } from '../../../services/hyper-link-popup.service';
import { DocHyperLinkEventRenderController } from '../hyper-link-event.render-controller';
import { MobileDocHyperLinkEventRenderController } from '../mobile/hyper-link-event.render-controller';
function createController(
context: IRenderContext<DocumentDataModel>,
events: DocEventManagerService,
commands: ICommandService,
popup: DocHyperLinkPopupService,
skeleton: DocSkeletonManagerService,
selection: DocSelectionManagerService
) {
const univer = new Univer();
const injector = univer.__getInjector();
injector.add([DocEventManagerService, { useValue: events }]);
injector.add([DocHyperLinkPopupService, { useValue: popup }]);
injector.add([DocSkeletonManagerService, { useValue: skeleton }]);
injector.add([DocSelectionManagerService, { useValue: selection }]);
const child = injector.createChild([[ICommandService, { useValue: commands }]]);
const controller = child.createInstance(DocHyperLinkEventRenderController, context);
controller.disposeWithMe(() => univer.dispose());
return controller;
}
describe('DocHyperLinkEventRenderController', () => {
it('keeps the mobile event controller independent from the desktop controller', () => {
expect(MobileDocHyperLinkEventRenderController.prototype).not.toBeInstanceOf(DocHyperLinkEventRenderController);
});
it('handles hyperlink menus and clicks in the standalone mobile controller', async () => {
const hoverCustomRanges$ = new Subject<unknown[]>();
const clickCustomRanges$ = new Subject<unknown>();
const pointerDownCustomRanges$ = new Subject<unknown[]>();
const commandService = { executeCommand: vi.fn() };
const popupService = {
canEditLink: vi.fn(() => true),
hideInfoPopupOnPointerDown: vi.fn(),
showEditPopup: vi.fn(),
showInfoPopup: vi.fn(),
};
const capture = vi.fn<(target: { onEdit: () => unknown; onDelete: () => unknown }) => boolean>(() => true);
const controller = new MobileDocHyperLinkEventRenderController(
{ unitId: 'doc-unit' } as never,
{ hoverCustomRanges$, clickCustomRanges$, pointerDownCustomRanges$ } as never,
commandService as never,
popupService as never,
{ getSkeleton: vi.fn() } as never,
{ capture } as never
);
const linkRange = {
range: {
rangeId: 'link-1',
rangeType: CustomRangeType.HYPERLINK,
startIndex: 4,
endIndex: 10,
},
segmentId: 'header-1',
segmentPageIndex: 0,
rects: [{ left: 10, top: 20, right: 50, bottom: 40 }],
};
pointerDownCustomRanges$.next([linkRange]);
const target = capture.mock.calls[0][0];
await target.onEdit();
await target.onDelete();
expect(popupService.showEditPopup).toHaveBeenCalledWith('doc-unit', {
unitId: 'doc-unit',
linkId: 'link-1',
segmentId: 'header-1',
segmentPage: 0,
startIndex: 4,
endIndex: 10,
});
expect(commandService.executeCommand).toHaveBeenCalledWith(DeleteDocHyperLinkCommand.id, {
unitId: 'doc-unit',
linkId: 'link-1',
segmentId: 'header-1',
segmentPage: 0,
startIndex: 4,
endIndex: 10,
});
clickCustomRanges$.next({ ...linkRange, ctrlKey: false, metaKey: false });
expect(popupService.showInfoPopup).toHaveBeenCalledWith(
expect.objectContaining({ unitId: 'doc-unit', linkId: 'link-1' }),
{ pinned: true }
);
commandService.executeCommand.mockClear();
clickCustomRanges$.next({ ...linkRange, ctrlKey: true, metaKey: false });
expect(commandService.executeCommand).toHaveBeenCalledWith(ClickDocHyperLinkOperation.id, {
unitId: 'doc-unit',
linkId: 'link-1',
segmentId: 'header-1',
});
pointerDownCustomRanges$.next([]);
expect(popupService.hideInfoPopupOnPointerDown).toHaveBeenCalledOnce();
controller.dispose();
capture.mockClear();
popupService.showInfoPopup.mockClear();
pointerDownCustomRanges$.next([linkRange]);
clickCustomRanges$.next({ ...linkRange, ctrlKey: false, metaKey: false });
expect(capture).not.toHaveBeenCalled();
expect(popupService.showInfoPopup).not.toHaveBeenCalled();
});
it('does not register mobile hyperlink events for the normal internal editor', () => {
const hoverCustomRanges$ = new Subject<unknown[]>();
const clickCustomRanges$ = new Subject<unknown>();
const pointerDownCustomRanges$ = new Subject<unknown[]>();
const capture = vi.fn();
const showInfoPopup = vi.fn();
const controller = new MobileDocHyperLinkEventRenderController(
{ unitId: DOCS_NORMAL_EDITOR_UNIT_ID_KEY } as never,
{ hoverCustomRanges$, clickCustomRanges$, pointerDownCustomRanges$ } as never,
{ executeCommand: vi.fn() } as never,
{ showInfoPopup } as never,
{ getSkeleton: vi.fn() } as never,
{ capture } as never
);
pointerDownCustomRanges$.next([{
range: { rangeType: CustomRangeType.HYPERLINK },
rects: [{ left: 0, top: 0, right: 1, bottom: 1 }],
}]);
clickCustomRanges$.next({
range: { rangeType: CustomRangeType.HYPERLINK },
ctrlKey: false,
metaKey: false,
});
expect(capture).not.toHaveBeenCalled();
expect(showInfoPopup).not.toHaveBeenCalled();
controller.dispose();
});
it('ignores hover ranges when the current selection has no text ranges', async () => {
const hoverCustomRanges$ = new Subject<unknown[]>();
const clickCustomRanges$ = new Subject<unknown>();
const pointerDownCustomRanges$ = new Subject<unknown[]>();
const onUnhandledError = vi.fn();
const previousUnhandledError = config.onUnhandledError;
const commandService = {
executeCommand: vi.fn(),
};
config.onUnhandledError = onUnhandledError;
try {
const controller = createController(
{ unitId: 'doc-unit' } as never,
{ hoverCustomRanges$, clickCustomRanges$, pointerDownCustomRanges$ } as never,
commandService as never,
{ showing: false } as never,
{ getSkeleton: vi.fn() } as never,
{ getTextRanges: () => [] } as never
);
hoverCustomRanges$.next([]);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(onUnhandledError).not.toHaveBeenCalled();
expect(commandService.executeCommand).not.toHaveBeenCalled();
controller.dispose();
} finally {
config.onUnhandledError = previousUnhandledError;
}
});
it('shows hyperlink details on click and opens only with Ctrl or Command click', () => {
const hoverCustomRanges$ = new Subject<unknown[]>();
const clickCustomRanges$ = new Subject<unknown>();
const pointerDownCustomRanges$ = new Subject<unknown[]>();
const commandService = {
executeCommand: vi.fn(),
};
const hyperLinkPopupService = {
showing: false,
showInfoPopup: vi.fn(),
};
const controller = createController(
{ unitId: 'doc-unit' } as never,
{ hoverCustomRanges$, clickCustomRanges$, pointerDownCustomRanges$ } as never,
commandService as never,
hyperLinkPopupService as never,
{ getSkeleton: vi.fn() } as never,
{ getTextRanges: () => [] } as never
);
const clickedRange = {
range: {
rangeId: 'link-1',
rangeType: CustomRangeType.HYPERLINK,
startIndex: 4,
endIndex: 10,
},
segmentId: 'header-1',
segmentPageIndex: 0,
rects: [],
};
clickCustomRanges$.next({ ...clickedRange, ctrlKey: false, metaKey: false });
expect(commandService.executeCommand).not.toHaveBeenCalled();
expect(hyperLinkPopupService.showInfoPopup).toHaveBeenCalledWith(
{
unitId: 'doc-unit',
linkId: 'link-1',
segmentId: 'header-1',
segmentPage: 0,
startIndex: 4,
endIndex: 10,
},
{ pinned: true }
);
clickCustomRanges$.next({ ...clickedRange, ctrlKey: true, metaKey: false });
expect(commandService.executeCommand).toHaveBeenLastCalledWith(ClickDocHyperLinkOperation.id, {
unitId: 'doc-unit',
linkId: 'link-1',
segmentId: 'header-1',
});
commandService.executeCommand.mockClear();
clickCustomRanges$.next({ ...clickedRange, ctrlKey: false, metaKey: true });
expect(commandService.executeCommand).toHaveBeenCalledWith(ClickDocHyperLinkOperation.id, {
unitId: 'doc-unit',
linkId: 'link-1',
segmentId: 'header-1',
});
controller.dispose();
});
it('keeps click-pinned hyperlink details visible when the pointer leaves the link', () => {
const hoverCustomRanges$ = new Subject<unknown[]>();
const clickCustomRanges$ = new Subject<unknown>();
const pointerDownCustomRanges$ = new Subject<unknown[]>();
const commandService = {
executeCommand: vi.fn(),
};
const hyperLinkPopupService = {
showing: false as false | { linkId: string },
infoPopupPinned: false,
showInfoPopup: vi.fn(function (this: { showing: false | { linkId: string }; infoPopupPinned: boolean }, _info, options?: { pinned?: boolean }) {
this.showing = { linkId: 'link-1' };
this.infoPopupPinned = options?.pinned ?? false;
}),
};
const controller = createController(
{ unitId: 'doc-unit' } as never,
{ hoverCustomRanges$, clickCustomRanges$, pointerDownCustomRanges$ } as never,
commandService as never,
hyperLinkPopupService as never,
{ getSkeleton: vi.fn() } as never,
{ getTextRanges: () => [] } as never
);
const clickedRange = {
range: {
rangeId: 'link-1',
rangeType: CustomRangeType.HYPERLINK,
startIndex: 4,
endIndex: 10,
},
segmentId: '',
segmentPageIndex: 0,
rects: [],
ctrlKey: false,
metaKey: false,
};
clickCustomRanges$.next(clickedRange);
hoverCustomRanges$.next([]);
expect(hyperLinkPopupService.infoPopupPinned).toBe(true);
expect(commandService.executeCommand).not.toHaveBeenCalled();
controller.dispose();
});
it('schedules hover-opened hyperlink details to close when the pointer leaves the link', () => {
const hoverCustomRanges$ = new Subject<unknown[]>();
const clickCustomRanges$ = new Subject<unknown>();
const pointerDownCustomRanges$ = new Subject<unknown[]>();
const commandService = {
executeCommand: vi.fn(),
};
const hyperLinkPopupService = {
showing: { linkId: 'link-1' },
infoPopupPinned: false,
scheduleHideInfoPopup: vi.fn(),
};
const controller = createController(
{ unitId: 'doc-unit' } as never,
{ hoverCustomRanges$, clickCustomRanges$, pointerDownCustomRanges$ } as never,
commandService as never,
hyperLinkPopupService as never,
{ getSkeleton: vi.fn() } as never,
{ getTextRanges: () => [] } as never
);
hoverCustomRanges$.next([]);
expect(hyperLinkPopupService.scheduleHideInfoPopup).toHaveBeenCalledTimes(1);
expect(commandService.executeCommand).not.toHaveBeenCalled();
controller.dispose();
});
it('hides the hyperlink popup immediately when pointer down is outside a hyperlink', () => {
const hoverCustomRanges$ = new Subject<unknown[]>();
const clickCustomRanges$ = new Subject<unknown>();
const pointerDownCustomRanges$ = new Subject<unknown[]>();
const commandService = {
executeCommand: vi.fn(),
};
const hyperLinkPopupService = {
showing: { linkId: 'link-1' },
hideInfoPopupOnPointerDown: vi.fn(),
};
const controller = createController(
{ unitId: 'doc-unit' } as never,
{ hoverCustomRanges$, clickCustomRanges$, pointerDownCustomRanges$ } as never,
commandService as never,
hyperLinkPopupService as never,
{ getSkeleton: vi.fn() } as never,
{ getTextRanges: () => [] } as never
);
pointerDownCustomRanges$.next([]);
expect(hyperLinkPopupService.hideInfoPopupOnPointerDown).toHaveBeenCalledTimes(1);
controller.dispose();
});
});

View File

@@ -0,0 +1,98 @@
/**
* 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 { DocumentDataModel } from '@univerjs/core';
import type { IRenderContext } from '@univerjs/engine-render';
import {
createDocumentModelWithStyle,
CustomRangeType,
IUniverInstanceService,
Univer,
UniverInstanceType,
} from '@univerjs/core';
import {
DocInterceptorService,
DocLayoutExecutorService,
DocSelectionManagerService,
DocSkeletonManagerService,
} from '@univerjs/docs';
import { DocCanvasPopManagerService } from '@univerjs/docs-ui';
import { IRenderManagerService } from '@univerjs/engine-render';
import { DesktopDialogService, IDialogService, IUIPartsService, UIPartsService } from '@univerjs/ui';
import { NEVER } from 'rxjs';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { DocHyperLinkPopupService } from '../../../services/hyper-link-popup.service';
import { DocHyperLinkRenderController } from '../render.controller';
describe('DocHyperLinkRenderController', () => {
afterEach(() => vi.restoreAllMocks());
it('updates popup state without requiring a document layout controller or changing page geometry', () => {
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
font: '',
measureText: (text: string) => ({ width: text.length * 8, actualBoundingBoxAscent: 8, actualBoundingBoxDescent: 2 }),
} as never);
const univer = new Univer();
const injector = univer.__getInjector();
const model = createDocumentModelWithStyle('Link text\r', {});
model.getBody()!.customRanges = [{ startIndex: 0, endIndex: 3, rangeId: 'link', rangeType: CustomRangeType.HYPERLINK }];
injector.get(IUniverInstanceService).__addUnit(model);
const context = { unit: model, unitId: model.getUnitId(), type: UniverInstanceType.UNIVER_DOC } as IRenderContext<DocumentDataModel>;
injector.add([DocLayoutExecutorService]);
injector.add([DocSelectionManagerService]);
injector.add([DocSkeletonManagerService, { useFactory: () => injector.createInstance(DocSkeletonManagerService, context) }]);
injector.add([DocInterceptorService, { useFactory: () => injector.createInstance(DocInterceptorService, context) }]);
injector.add([IRenderManagerService, { useValue: { disposed$: NEVER } as unknown as IRenderManagerService }]);
// Canvas popup mounting is the unavailable platform boundary in this unit test.
const disposePopup = vi.fn();
injector.add([DocCanvasPopManagerService, { useValue: {
attachPopupToRange: () => ({ dispose: disposePopup, canDispose: () => true }),
} as unknown as DocCanvasPopManagerService }]);
injector.add([DocHyperLinkPopupService]);
injector.add([IUIPartsService, { useClass: UIPartsService }]);
injector.add([IDialogService, { useClass: DesktopDialogService }]);
injector.add([DocHyperLinkRenderController, { useFactory: () => injector.createInstance(DocHyperLinkRenderController, context) }]);
const controller = injector.get(DocHyperLinkRenderController);
const skeletonManager = injector.get(DocSkeletonManagerService);
const skeleton = skeletonManager.getSkeleton();
const pages = skeleton.getSkeletonData()!.pages;
const calculate = vi.spyOn(skeleton, 'calculate');
const startLayout = vi.spyOn(skeleton, 'startIncrementalLayout');
const popup = injector.get(DocHyperLinkPopupService);
const link = { unitId: model.getUnitId(), linkId: 'link', startIndex: 0, endIndex: 3 };
try {
for (let click = 0; click < 3; click++) {
popup.showInfoPopup(link);
expect(popup.showing).toMatchObject(link);
expect(skeletonManager.getViewModel().getCustomRange(0)?.active).toBe(true);
popup.hideInfoPopup();
expect(popup.showing).toBeNull();
expect(skeletonManager.getViewModel().getCustomRange(0)?.active).toBe(false);
}
expect(calculate).not.toHaveBeenCalled();
expect(startLayout).not.toHaveBeenCalled();
expect(skeleton.getSkeletonData()!.pages).toBe(pages);
expect(disposePopup).toHaveBeenCalled();
} finally {
controller.dispose();
skeleton.dispose();
univer.dispose();
}
});
});

View File

@@ -0,0 +1,144 @@
/**
* 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 { DocumentDataModel } from '@univerjs/core';
import type { IRenderContext, IRenderModule } from '@univerjs/engine-render';
import {
CustomRangeType,
Disposable,
DOCS_NORMAL_EDITOR_UNIT_ID_KEY,
ICommandService,
Inject,
} from '@univerjs/core';
import { DocSelectionManagerService, DocSkeletonManagerService } from '@univerjs/docs';
import { DocEventManagerService } from '@univerjs/docs-ui';
import {
ClickDocHyperLinkOperation,
ToggleDocHyperLinkInfoPopupOperation,
} from '../../commands/operations/popup.operation';
import { DocHyperLinkPopupService } from '../../services/hyper-link-popup.service';
export class DocHyperLinkEventRenderController extends Disposable implements IRenderModule {
get _skeleton() {
return this._docSkeletonManagerService.getSkeleton();
}
constructor(
private readonly _context: IRenderContext<DocumentDataModel>,
@Inject(DocEventManagerService) private readonly _docEventManagerService: DocEventManagerService,
@ICommandService private readonly _commandService: ICommandService,
@Inject(DocHyperLinkPopupService) private readonly _hyperLinkPopupService: DocHyperLinkPopupService,
@Inject(DocSkeletonManagerService) private readonly _docSkeletonManagerService: DocSkeletonManagerService,
@Inject(DocSelectionManagerService) private readonly _docSelectionManagerService: DocSelectionManagerService
) {
super();
if (this._context.unitId === DOCS_NORMAL_EDITOR_UNIT_ID_KEY) {
return;
}
this._initPointerDown();
this._initHover();
this._initClick();
}
private _hideInfoPopup() {
if (this._hyperLinkPopupService.infoPopupPinned) {
return;
}
if (this._hyperLinkPopupService.showing) {
this._hyperLinkPopupService.scheduleHideInfoPopup();
}
}
private _initPointerDown() {
this.disposeWithMe(
this._docEventManagerService.pointerDownCustomRanges$.subscribe((ranges) => {
const link = ranges.find((range) => range.range.rangeType === CustomRangeType.HYPERLINK);
if (!link) {
this._hyperLinkPopupService.hideInfoPopupOnPointerDown();
}
})
);
}
private _initHover() {
this.disposeWithMe(
this._docEventManagerService.hoverCustomRanges$.subscribe((ranges) => {
const link = ranges.find((range) => range.range.rangeType === CustomRangeType.HYPERLINK);
const activeRanges = this._docSelectionManagerService.getTextRanges();
const currentSegmentId = activeRanges?.[0]?.segmentId;
if ((link?.segmentId ?? '') !== currentSegmentId) {
this._hideInfoPopup();
return;
}
if (link) {
const info = {
unitId: this._context.unitId,
linkId: link.range.rangeId,
segmentId: link.segmentId,
segmentPage: link.segmentPageIndex,
startIndex: link.range.startIndex,
endIndex: link.range.endIndex,
};
this._commandService.executeCommand(
ToggleDocHyperLinkInfoPopupOperation.id,
info
);
} else {
this._hideInfoPopup();
}
})
);
}
private _initClick() {
this.disposeWithMe(
this._docEventManagerService.clickCustomRanges$.subscribe((range) => {
const link = range.range;
if (link.rangeType !== CustomRangeType.HYPERLINK) {
return;
}
if (!range.ctrlKey && !range.metaKey) {
this._hyperLinkPopupService.showInfoPopup(
{
unitId: this._context.unitId,
linkId: link.rangeId,
segmentId: range.segmentId,
segmentPage: range.segmentPageIndex,
startIndex: link.startIndex,
endIndex: link.endIndex,
},
{ pinned: true }
);
return;
}
this._commandService.executeCommand(
ClickDocHyperLinkOperation.id,
{
unitId: this._context.unitId,
linkId: link.rangeId,
segmentId: range.segmentId,
}
);
})
);
}
}

View File

@@ -0,0 +1,117 @@
/**
* 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 { DocumentDataModel } from '@univerjs/core';
import type { IRenderContext, IRenderModule } from '@univerjs/engine-render';
import {
CustomRangeType,
Disposable,
DOCS_NORMAL_EDITOR_UNIT_ID_KEY,
ICommandService,
Inject,
} from '@univerjs/core';
import { DocSkeletonManagerService } from '@univerjs/docs';
import { DocEventManagerService, DocMobileElementMenuService } from '@univerjs/docs-ui';
import { DeleteDocHyperLinkCommand } from '../../../commands/commands/delete-link.command';
import { ClickDocHyperLinkOperation } from '../../../commands/operations/popup.operation';
import { DocHyperLinkPopupService } from '../../../services/hyper-link-popup.service';
export class MobileDocHyperLinkEventRenderController extends Disposable implements IRenderModule {
get _skeleton() {
return this._docSkeletonManagerService.getSkeleton();
}
constructor(
private readonly _context: IRenderContext<DocumentDataModel>,
@Inject(DocEventManagerService) private readonly _docEventManagerService: DocEventManagerService,
@ICommandService private readonly _commandService: ICommandService,
@Inject(DocHyperLinkPopupService) private readonly _hyperLinkPopupService: DocHyperLinkPopupService,
@Inject(DocSkeletonManagerService) private readonly _docSkeletonManagerService: DocSkeletonManagerService,
@Inject(DocMobileElementMenuService) private readonly _mobileElementMenuService: DocMobileElementMenuService
) {
super();
if (this._context.unitId === DOCS_NORMAL_EDITOR_UNIT_ID_KEY) {
return;
}
this._initPointerDown();
this._initClick();
}
private _initPointerDown(): void {
this.disposeWithMe(this._docEventManagerService.pointerDownCustomRanges$.subscribe((ranges) => {
const link = ranges.find((range) => range.range.rangeType === CustomRangeType.HYPERLINK);
if (!link) {
this._hyperLinkPopupService.hideInfoPopupOnPointerDown();
return;
}
const info = {
unitId: this._context.unitId,
linkId: link.range.rangeId,
segmentId: link.segmentId,
segmentPage: link.segmentPageIndex,
startIndex: link.range.startIndex,
endIndex: link.range.endIndex,
};
const rect = link.rects[0];
if (rect && this._hyperLinkPopupService.canEditLink(info.unitId, info)) {
this._mobileElementMenuService.capture({
unitId: info.unitId,
rect,
onEdit: () => this._hyperLinkPopupService.showEditPopup(info.unitId, info),
onDelete: () => this._commandService.executeCommand(DeleteDocHyperLinkCommand.id, info),
});
}
}));
}
private _initClick(): void {
this.disposeWithMe(
this._docEventManagerService.clickCustomRanges$.subscribe((range) => {
const link = range.range;
if (link.rangeType !== CustomRangeType.HYPERLINK) {
return;
}
if (!range.ctrlKey && !range.metaKey) {
this._hyperLinkPopupService.showInfoPopup(
{
unitId: this._context.unitId,
linkId: link.rangeId,
segmentId: range.segmentId,
segmentPage: range.segmentPageIndex,
startIndex: link.startIndex,
endIndex: link.endIndex,
},
{ pinned: true }
);
return;
}
this._commandService.executeCommand(
ClickDocHyperLinkOperation.id,
{
unitId: this._context.unitId,
linkId: link.rangeId,
segmentId: range.segmentId,
}
);
})
);
}
}

View File

@@ -0,0 +1,61 @@
/**
* 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 { DocumentDataModel } from '@univerjs/core';
import type { IRenderContext, IRenderModule } from '@univerjs/engine-render';
import { Disposable, Inject } from '@univerjs/core';
import { DOC_INTERCEPTOR_POINT, DocInterceptorService } from '@univerjs/docs';
import { DocHyperLinkPopupService } from '../../services/hyper-link-popup.service';
export class DocHyperLinkRenderController extends Disposable implements IRenderModule {
constructor(
_context: IRenderContext<DocumentDataModel>,
@Inject(DocInterceptorService) private readonly _docInterceptorService: DocInterceptorService,
@Inject(DocHyperLinkPopupService) private readonly _hyperLinkService: DocHyperLinkPopupService
) {
super();
// Hyperlink underline/color no longer depend on popup activation. Opening
// or closing the popup must not schedule a document layout generation.
this._init();
}
private _init() {
this._docInterceptorService.intercept(DOC_INTERCEPTOR_POINT.CUSTOM_RANGE, {
handler: (data, pos, next) => {
if (!data) {
return next(data);
}
const { unitId, index } = pos;
const activeLink = this._hyperLinkService.showing;
if (!activeLink) {
return next({
...data,
active: false,
});
}
const { linkId, unitId: linkUnitId, startIndex, endIndex } = activeLink;
const isActive = linkUnitId === unitId && data.rangeId === linkId && index >= startIndex && index <= endIndex;
return next({
...data,
active: isActive,
});
},
});
}
}

View File

@@ -0,0 +1,67 @@
/**
* 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 { MenuConfig } from '@univerjs/ui';
import { Disposable, ICommandService } from '@univerjs/core';
import { FLOAT_TOOLBAR_MENU_POSITION } from '@univerjs/docs-ui';
import { IMenuManagerService, IShortcutService } from '@univerjs/ui';
import { AddDocHyperLinkCommand } from '../commands/commands/add-link.command';
import { DeleteDocHyperLinkCommand } from '../commands/commands/delete-link.command';
import { UpdateDocHyperLinkCommand } from '../commands/commands/update-link.command';
import { ClickDocHyperLinkOperation, ShowDocHyperLinkEditPopupOperation, ToggleDocHyperLinkInfoPopupOperation } from '../commands/operations/popup.operation';
import { addLinkShortcut } from '../menu/menu';
import { menuSchema } from '../menu/schema';
export interface IDocHyperLinkUIConfig {
menu: MenuConfig;
}
export class DocHyperLinkUIController extends Disposable {
constructor(
@ICommandService private readonly _commandService: ICommandService,
@IMenuManagerService private readonly _menuManagerService: IMenuManagerService,
@IShortcutService private readonly _shortcutService: IShortcutService
) {
super();
this._initCommands();
this._initMenus();
this._initShortcut();
}
private _initCommands() {
[
AddDocHyperLinkCommand,
UpdateDocHyperLinkCommand,
DeleteDocHyperLinkCommand,
ShowDocHyperLinkEditPopupOperation,
ToggleDocHyperLinkInfoPopupOperation,
ClickDocHyperLinkOperation,
].forEach((command) => {
this._commandService.registerCommand(command);
});
}
private _initShortcut() {
[addLinkShortcut].forEach((shortcut) => {
this._shortcutService.registerShortcut(shortcut);
});
}
private _initMenus() {
this._menuManagerService.appendRootMenu({ [FLOAT_TOOLBAR_MENU_POSITION]: {} });
this._menuManagerService.mergeMenu(menuSchema);
}
}

View File

@@ -0,0 +1,2 @@
@tailwind components;
@tailwind utilities;

View File

@@ -0,0 +1,22 @@
/**
* 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 './global.css';
export type { IUniverDocsHyperLinkUIConfig } from './config/config';
export { menuSchema as DocsHyperLinkUIMenuSchema } from './menu/schema';
export { UniverDocsHyperLinkMobileUIPlugin } from './mobile-plugin';
export { UniverDocsHyperLinkUIPlugin } from './plugin';

View File

@@ -0,0 +1,41 @@
/**
* 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 enUS from './en-US';
const locale: typeof enUS = {
'docs-hyper-link-ui': {
edit: {
confirm: 'تأكيد',
cancel: 'إلغاء',
address: 'ارتباط',
addressError: 'عنوان URL غير قانوني!',
label: 'تسمية',
labelError: 'يُرجى إدخال تسمية الارتباط',
},
info: {
copy: 'نسخ',
edit: 'تحرير',
cancel: 'إلغاء الارتباط',
coped: 'تم نسخ الارتباط إلى الحافظة',
},
menu: {
tooltip: 'إضافة ارتباط',
},
},
};
export default locale;

View File

@@ -0,0 +1,41 @@
/**
* 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 enUS from './en-US';
const locale: typeof enUS = {
'docs-hyper-link-ui': {
edit: {
confirm: 'Confirma',
cancel: 'Cancel·la',
address: 'Enllaç',
addressError: 'La URL no és vàlida!',
label: 'Etiqueta',
labelError: 'Si us plau, introdueix l\'etiqueta de l\'enllaç',
},
info: {
copy: 'Copia',
edit: 'Edita',
cancel: 'Elimina l\'enllaç',
coped: 'Enllaç copiat al porta-retalls',
},
menu: {
tooltip: 'Afegeix enllaç',
},
},
};
export default locale;

View File

@@ -0,0 +1,41 @@
/**
* 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 enUS from './en-US';
const locale: typeof enUS = {
'docs-hyper-link-ui': {
edit: {
confirm: 'Bestätigen',
cancel: 'Abbrechen',
address: 'Link',
addressError: 'URL ist ungültig!',
label: 'Bezeichnung',
labelError: 'Bitte die Bezeichnung des Links eingeben',
},
info: {
copy: 'Kopieren',
edit: 'Bearbeiten',
cancel: 'Link abbrechen',
coped: 'Link in die Zwischenablage kopiert',
},
menu: {
tooltip: 'Link hinzufügen',
},
},
};
export default locale;

View File

@@ -0,0 +1,39 @@
/**
* 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 locale = {
'docs-hyper-link-ui': {
edit: {
confirm: 'Confirm',
cancel: 'Cancel',
address: 'Link',
addressError: 'Url is illegal!',
label: 'Label',
labelError: 'Please input label of link',
},
info: {
copy: 'Copy',
edit: 'Edit',
cancel: 'Cancel link',
coped: 'Link copied to clipboard',
},
menu: {
tooltip: 'Add link',
},
},
};
export default locale;

View File

@@ -0,0 +1,41 @@
/**
* 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 enUS from './en-US';
const locale: typeof enUS = {
'docs-hyper-link-ui': {
edit: {
confirm: 'Confirmar',
cancel: 'Cancelar',
address: 'Enlace',
addressError: '¡La URL no es válida!',
label: 'Etiqueta',
labelError: 'Por favor, introduce la etiqueta del enlace',
},
info: {
copy: 'Copiar',
edit: 'Editar',
cancel: 'Eliminar enlace',
coped: 'Enlace copiado al portapapeles',
},
menu: {
tooltip: 'Añadir enlace',
},
},
};
export default locale;

View File

@@ -0,0 +1,41 @@
/**
* 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 enUS from './en-US';
const locale: typeof enUS = {
'docs-hyper-link-ui': {
edit: {
confirm: 'تایید',
cancel: 'انصراف',
address: 'لینک',
addressError: 'آدرس غیرمجاز است!',
label: 'برچسب',
labelError: 'لطفا برچسب لینک را وارد کنید',
},
info: {
copy: 'کپی',
edit: 'ویرایش',
cancel: 'لینک را لغو کنید',
coped: 'لینک به کلیپ بورد کپی شد',
},
menu: {
tooltip: 'افزودن لینک',
},
},
};
export default locale;

View File

@@ -0,0 +1,41 @@
/**
* 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 enUS from './en-US';
const locale: typeof enUS = {
'docs-hyper-link-ui': {
edit: {
confirm: 'Confirmer',
cancel: 'Annuler',
address: 'Lien',
addressError: 'L\'url est illégal!',
label: 'Libellé',
labelError: 'Veuillez entrer le label du lien',
},
info: {
copy: 'Copier',
edit: 'Modifier',
cancel: 'Supprimer le lien',
coped: 'Lien copié dans le presse-papiers',
},
menu: {
tooltip: 'Ajouter un lien',
},
},
};
export default locale;

View File

@@ -0,0 +1,41 @@
/**
* 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 enUS from './en-US';
const locale: typeof enUS = {
'docs-hyper-link-ui': {
edit: {
confirm: 'Konfirmasi',
cancel: 'Batal',
address: 'Tautan',
addressError: 'URL tidak valid!',
label: 'Label',
labelError: 'Silakan masukkan label tautan',
},
info: {
copy: 'Salin',
edit: 'Edit',
cancel: 'Batalkan tautan',
coped: 'Tautan disalin ke clipboard',
},
menu: {
tooltip: 'Tambah tautan',
},
},
};
export default locale;

View File

@@ -0,0 +1,41 @@
/**
* 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 enUS from './en-US';
const locale: typeof enUS = {
'docs-hyper-link-ui': {
edit: {
confirm: 'Conferma',
cancel: 'Annulla',
address: 'Collegamento',
addressError: 'URL non valido!',
label: 'Etichetta',
labelError: 'Inserisci l\'etichetta del collegamento',
},
info: {
copy: 'Copia',
edit: 'Modifica',
cancel: 'Annulla collegamento',
coped: 'Collegamento copiato negli appunti',
},
menu: {
tooltip: 'Aggiungi collegamento',
},
},
};
export default locale;

View File

@@ -0,0 +1,41 @@
/**
* 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 enUS from './en-US';
const locale: typeof enUS = {
'docs-hyper-link-ui': {
edit: {
confirm: 'OK',
cancel: 'キャンセル',
address: 'アドレス',
addressError: 'URLが正しくありません!',
label: '表示文字列',
labelError: 'リンクの表示文字列を入力してください',
},
info: {
copy: 'コピー',
edit: '編集',
cancel: 'リンク解除',
coped: 'リンクがクリップボードにコピーされました',
},
menu: {
tooltip: 'ハイパーリンクの挿入',
},
},
};
export default locale;

View File

@@ -0,0 +1,41 @@
/**
* 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 enUS from './en-US';
const locale: typeof enUS = {
'docs-hyper-link-ui': {
edit: {
confirm: '확인',
cancel: '취소',
address: '링크',
addressError: 'URL이 올바르지 않습니다!',
label: '라벨',
labelError: '링크 라벨을 입력해주세요',
},
info: {
copy: '복사',
edit: '편집',
cancel: '링크 취소',
coped: '링크가 클립보드에 복사되었습니다',
},
menu: {
tooltip: '링크 추가',
},
},
};
export default locale;

View File

@@ -0,0 +1,41 @@
/**
* 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 enUS from './en-US';
const locale: typeof enUS = {
'docs-hyper-link-ui': {
edit: {
confirm: 'Potwierdź',
cancel: 'Anuluj',
address: 'Łącze',
addressError: 'Adres URL jest nieprawidłowy!',
label: 'Etykieta',
labelError: 'Wprowadź etykietę łącza',
},
info: {
copy: 'Kopiuj',
edit: 'Edytuj',
cancel: 'Usuń łącze',
coped: 'Łącze skopiowane do schowka',
},
menu: {
tooltip: 'Dodaj łącze',
},
},
};
export default locale;

View File

@@ -0,0 +1,41 @@
/**
* 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 enUS from './en-US';
const locale: typeof enUS = {
'docs-hyper-link-ui': {
edit: {
confirm: 'Confirmar',
cancel: 'Cancelar',
address: 'Link',
addressError: 'URL inválida!',
label: 'Rótulo',
labelError: 'Insira o rótulo do link',
},
info: {
copy: 'Copiar',
edit: 'Editar',
cancel: 'Cancelar link',
coped: 'Link copiado para a área de transferência',
},
menu: {
tooltip: 'Adicionar link',
},
},
};
export default locale;

View File

@@ -0,0 +1,41 @@
/**
* 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 enUS from './en-US';
const locale: typeof enUS = {
'docs-hyper-link-ui': {
edit: {
confirm: 'Подтвердить',
cancel: 'Отменить',
address: 'Ссылка',
label: 'Текст',
labelError: 'Введите текст ссылки',
addressError: 'Пожалуйста, введите корректную ссылку',
},
info: {
copy: 'Копировать',
edit: 'Редактировать',
cancel: 'Отменить ссылку',
coped: 'Ссылка скопирована в буфер обмена',
},
menu: {
tooltip: 'Добавить ссылку',
},
},
};
export default locale;

View File

@@ -0,0 +1,41 @@
/**
* 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 enUS from './en-US';
const locale: typeof enUS = {
'docs-hyper-link-ui': {
edit: {
confirm: 'Potvrdiť',
cancel: 'Zrušiť',
address: 'Odkaz',
addressError: 'URL je neplatná!',
label: 'Popisok',
labelError: 'Zadajte popis odkazu',
},
info: {
copy: 'Kopírovať',
edit: 'Upraviť',
cancel: 'Zrušiť odkaz',
coped: 'Odkaz skopírovaný do schránky',
},
menu: {
tooltip: 'Pridať odkaz',
},
},
};
export default locale;

View File

@@ -0,0 +1,20 @@
/**
* 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 { LocaleLeafKeys } from '@univerjs/core';
import type enUS from './en-US';
export type LocaleKey = LocaleLeafKeys<typeof enUS>;

View File

@@ -0,0 +1,41 @@
/**
* 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 enUS from './en-US';
const locale: typeof enUS = {
'docs-hyper-link-ui': {
edit: {
confirm: 'Xác nhận',
cancel: 'Hủy',
address: 'Liên kết',
label: 'Nhãn',
labelError: 'Vui lòng nhập nhãn liên kết',
addressError: 'Vui lòng nhập liên kết hợp lệ',
},
info: {
copy: 'Sao chép',
edit: 'Chỉnh sửa',
cancel: 'Hủy liên kết',
coped: 'Liên kết đã được sao chép vào clipboard',
},
menu: {
tooltip: 'Thêm liên kết',
},
},
};
export default locale;

View File

@@ -0,0 +1,42 @@
/**
* 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 enUS from './en-US';
const locale: typeof enUS = {
'docs-hyper-link-ui': {
edit: {
confirm: '确认',
cancel: '取消',
address: '链接',
label: '文本',
labelError: '请输入链接文本',
addressError: '请输入合法的链接',
},
info: {
copy: '复制',
edit: '编辑',
cancel: '取消链接',
coped: '链接已被复制到剪贴板',
},
menu: {
tooltip: '添加链接',
},
},
};
export default locale;

View File

@@ -0,0 +1,41 @@
/**
* 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 enUS from './en-US';
const locale: typeof enUS = {
'docs-hyper-link-ui': {
edit: {
confirm: '確認',
cancel: '取消',
address: '連結',
label: '文字',
labelError: '請輸入連結文字',
addressError: '請輸入合法的連結',
},
info: {
copy: '複製',
edit: '編輯',
cancel: '取消連結',
coped: '連結已複製到剪貼板',
},
menu: {
tooltip: '新增連結',
},
},
};
export default locale;

View File

@@ -0,0 +1,41 @@
/**
* 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 enUS from './en-US';
const locale: typeof enUS = {
'docs-hyper-link-ui': {
edit: {
confirm: '確認',
cancel: '取消',
address: '連結',
label: '文字',
labelError: '請輸入連結文字',
addressError: '請輸入合法的連結',
},
info: {
copy: '複製',
edit: '編輯',
cancel: '取消連結',
coped: '連結已複製到剪貼板',
},
menu: {
tooltip: '新增連結',
},
},
};
export default locale;

View File

@@ -0,0 +1,53 @@
/**
* 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 { IAccessor } from '@univerjs/core';
import type { IMenuButtonItem, IShortcutItem } from '@univerjs/ui';
import type { LocaleKey } from '../locale/types';
import { UniverInstanceType } from '@univerjs/core';
import { DocSelectionManagerService } from '@univerjs/docs';
import { whenDocAndEditorFocused } from '@univerjs/docs-ui';
import { getMenuHiddenObservable, KeyCode, MenuItemType, MetaKeys } from '@univerjs/ui';
import { debounceTime, Observable } from 'rxjs';
import { shouldDisableAddLink, ShowDocHyperLinkEditPopupOperation } from '../commands/operations/popup.operation';
export function AddHyperLinkMenuItemFactory(accessor: IAccessor): IMenuButtonItem<LocaleKey> {
return {
id: ShowDocHyperLinkEditPopupOperation.id,
type: MenuItemType.BUTTON,
icon: 'LinkIcon',
title: 'docs-hyper-link-ui.menu.tooltip',
tooltip: 'docs-hyper-link-ui.menu.tooltip',
hidden$: getMenuHiddenObservable(accessor, UniverInstanceType.UNIVER_DOC),
disabled$: new Observable(function (subscribe) {
const textSelectionService = accessor.get(DocSelectionManagerService);
const observer = textSelectionService.textSelection$.pipe(debounceTime(16)).subscribe(() => {
subscribe.next(shouldDisableAddLink(accessor));
});
return () => {
observer.unsubscribe();
};
}),
};
}
export const addLinkShortcut: IShortcutItem = {
id: ShowDocHyperLinkEditPopupOperation.id,
binding: MetaKeys.CTRL_COMMAND | KeyCode.K,
description: 'docs-hyper-link-ui.menu.tooltip',
preconditions: whenDocAndEditorFocused,
};

View File

@@ -0,0 +1,62 @@
/**
* 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 { MenuSchemaType } from '@univerjs/ui';
import { EMPTY_PARAGRAPH_MENU_ID, FLOAT_TOOLBAR_MENU_POSITION, INSERT_BELLOW_MENU_ID } from '@univerjs/docs-ui';
import { ContextMenuGroup, ContextMenuPosition, RibbonInsertGroup } from '@univerjs/ui';
import { ShowDocHyperLinkEditPopupOperation } from '../commands/operations/popup.operation';
import { AddHyperLinkMenuItemFactory } from './menu';
export const menuSchema: MenuSchemaType = {
[RibbonInsertGroup.MEDIA]: {
[ShowDocHyperLinkEditPopupOperation.id]: {
order: 1,
menuItemFactory: AddHyperLinkMenuItemFactory,
},
},
[FLOAT_TOOLBAR_MENU_POSITION]: {
[ShowDocHyperLinkEditPopupOperation.id]: {
order: 20,
menuItemFactory: AddHyperLinkMenuItemFactory,
},
},
[ContextMenuPosition.MAIN_AREA]: {
[ContextMenuGroup.DATA]: {
[ShowDocHyperLinkEditPopupOperation.id]: {
order: 0,
menuItemFactory: AddHyperLinkMenuItemFactory,
},
},
},
[ContextMenuPosition.PARAGRAPH]: {
[ContextMenuGroup.LAYOUT]: {
[INSERT_BELLOW_MENU_ID]: {
[ShowDocHyperLinkEditPopupOperation.id]: {
order: 6,
menuItemFactory: AddHyperLinkMenuItemFactory,
},
},
},
[EMPTY_PARAGRAPH_MENU_ID]: {
[ContextMenuGroup.LAYOUT]: {
[ShowDocHyperLinkEditPopupOperation.id]: {
order: 6,
menuItemFactory: AddHyperLinkMenuItemFactory,
},
},
},
},
};

View File

@@ -0,0 +1,90 @@
/**
* 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 } from '@univerjs/core';
import type { IUniverDocsHyperLinkUIConfig } from './config/config';
import { DependentOn, IConfigService, Inject, Injector, merge, Plugin, UniverInstanceType } from '@univerjs/core';
import { UniverDocsPlugin } from '@univerjs/docs';
import { UniverDocsHyperLinkPlugin } from '@univerjs/docs-hyper-link';
import { UniverDocsMobileUIPlugin } from '@univerjs/docs-ui';
import { IRenderManagerService, UniverRenderEnginePlugin } from '@univerjs/engine-render';
import pkg from '../package.json';
import { defaultPluginConfig, DOCS_HYPER_LINK_UI_PLUGIN_CONFIG_KEY } from './config/config';
import { DocHyperLinkSelectionController } from './controllers/doc-hyper-link-selection.controller';
import { MobileComponentsController } from './controllers/mobile/components.controller';
import { MobileDocHyperLinkEventRenderController } from './controllers/render-controllers/mobile/hyper-link-event.render-controller';
import { DocHyperLinkRenderController } from './controllers/render-controllers/render.controller';
import { DocHyperLinkUIController } from './controllers/ui.controller';
import { DocHyperLinkPopupService } from './services/hyper-link-popup.service';
import { MobileDocHyperLinkPopupService } from './services/mobile/hyper-link-popup.service';
import { DOC_HYPER_LINK_UI_PLUGIN } from './types/const';
@DependentOn(
UniverDocsPlugin,
UniverRenderEnginePlugin,
UniverDocsHyperLinkPlugin,
UniverDocsMobileUIPlugin
)
export class UniverDocsHyperLinkMobileUIPlugin extends Plugin {
static override pluginName = DOC_HYPER_LINK_UI_PLUGIN;
static override packageName = pkg.name;
static override version = pkg.version;
static override type = UniverInstanceType.UNIVER_DOC;
constructor(
private readonly _config: Partial<IUniverDocsHyperLinkUIConfig> = defaultPluginConfig,
@Inject(Injector) protected override _injector: Injector,
@IRenderManagerService private readonly _renderManagerSrv: IRenderManagerService,
@IConfigService private readonly _configService: IConfigService
) {
super();
const { menu, ...rest } = merge(
{},
defaultPluginConfig,
this._config
);
if (menu) {
this._configService.setConfig('menu', menu, { merge: true });
}
this._configService.setConfig(DOCS_HYPER_LINK_UI_PLUGIN_CONFIG_KEY, rest);
}
override onStarting(): void {
const dependencies: Dependency[] = [
[MobileComponentsController],
[DocHyperLinkPopupService, { useClass: MobileDocHyperLinkPopupService }],
[DocHyperLinkUIController],
[DocHyperLinkSelectionController],
];
dependencies.forEach((dep) => this._injector.add(dep));
this._injector.get(MobileComponentsController);
this._injector.get(DocHyperLinkUIController);
}
override onReady(): void {
this._injector.get(DocHyperLinkSelectionController);
}
override onRendered(): void {
([
[DocHyperLinkRenderController],
[MobileDocHyperLinkEventRenderController],
] as Dependency[]).forEach((dep) => {
this._renderManagerSrv.registerRenderModule(UniverInstanceType.UNIVER_DOC, dep);
});
}
}

View File

@@ -0,0 +1,98 @@
/**
* 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 } from '@univerjs/core';
import type { IUniverDocsHyperLinkUIConfig } from './config/config';
import { DependentOn, IConfigService, Inject, Injector, merge, Plugin, UniverInstanceType } from '@univerjs/core';
import { UniverDocsPlugin } from '@univerjs/docs';
import { UniverDocsHyperLinkPlugin } from '@univerjs/docs-hyper-link';
import { UniverDocsUIPlugin } from '@univerjs/docs-ui';
import { IRenderManagerService, UniverRenderEnginePlugin } from '@univerjs/engine-render';
import pkg from '../package.json';
import { defaultPluginConfig, DOCS_HYPER_LINK_UI_PLUGIN_CONFIG_KEY } from './config/config';
import { ComponentsController } from './controllers/components.controller';
import { DocHyperLinkSelectionController } from './controllers/doc-hyper-link-selection.controller';
import { DocHyperLinkEventRenderController } from './controllers/render-controllers/hyper-link-event.render-controller';
import { DocHyperLinkRenderController } from './controllers/render-controllers/render.controller';
import { DocHyperLinkUIController } from './controllers/ui.controller';
import { DocHyperLinkPopupService } from './services/hyper-link-popup.service';
import { DOC_HYPER_LINK_UI_PLUGIN } from './types/const';
@DependentOn(
UniverDocsPlugin,
UniverRenderEnginePlugin,
UniverDocsHyperLinkPlugin,
UniverDocsUIPlugin
)
export class UniverDocsHyperLinkUIPlugin extends Plugin {
static override pluginName = DOC_HYPER_LINK_UI_PLUGIN;
static override packageName = pkg.name;
static override version = pkg.version;
static override type = UniverInstanceType.UNIVER_DOC;
constructor(
private readonly _config: Partial<IUniverDocsHyperLinkUIConfig> = defaultPluginConfig,
@Inject(Injector) protected override _injector: Injector,
@IRenderManagerService private readonly _renderManagerSrv: IRenderManagerService,
@IConfigService private readonly _configService: IConfigService
) {
super();
// Manage the plugin configuration.
const { menu, ...rest } = merge(
{},
defaultPluginConfig,
this._config
);
if (menu) {
this._configService.setConfig('menu', menu, { merge: true });
}
this._configService.setConfig(DOCS_HYPER_LINK_UI_PLUGIN_CONFIG_KEY, rest);
}
override onStarting(): void {
const deps: Dependency[] = [
[ComponentsController],
[DocHyperLinkPopupService],
[DocHyperLinkUIController],
[DocHyperLinkSelectionController],
];
deps.forEach((dep) => {
this._injector.add(dep);
});
this._injector.get(ComponentsController);
this._injector.get(DocHyperLinkUIController);
}
override onReady(): void {
this._injector.get(DocHyperLinkSelectionController);
}
override onRendered(): void {
this._initRenderModule();
}
private _initRenderModule() {
([
[DocHyperLinkRenderController],
[DocHyperLinkEventRenderController],
] as Dependency[]).forEach((dep) => {
this._renderManagerSrv.registerRenderModule(UniverInstanceType.UNIVER_DOC, dep);
});
}
}

View File

@@ -0,0 +1,337 @@
/**
* 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 {
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';
import { UnitAction } from '@univerjs/protocol';
import { IDialogService } from '@univerjs/ui';
import { of } from 'rxjs';
import { describe, expect, it, vi } from 'vitest';
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 CapturingDocCanvasPopManagerServiceCtor = CapturingDocCanvasPopManagerService as unknown as typeof DocCanvasPopManagerService;
function createService() {
const univer = new Univer();
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([DocSelectionManagerService]);
injector.add([DocHyperLinkPopupService]);
univer.createUnit(UniverInstanceType.UNIVER_DOC, { id: 'doc-1' });
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);
return {
service: injector.get(DocHyperLinkPopupService),
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,
};
}
describe('DocHyperLinkPopupService', () => {
it('closes link popups only when their owning Render is disposed', () => {
const { service, disposed, disposeRender } = createService();
const link = {
unitId: 'doc-1',
linkId: 'link-1',
startIndex: 4,
endIndex: 8,
};
service.showEditPopup('doc-1', link);
service.showInfoPopup(link);
disposeRender('doc-2');
expect(disposed).toEqual([]);
disposeRender('doc-1');
expect(disposed).toEqual([1, 2]);
});
it('releases owned link popups when the service is disposed', () => {
const { service, disposed } = createService();
const link = {
unitId: 'doc-1',
linkId: 'link-1',
startIndex: 4,
endIndex: 8,
};
service.showEditPopup('doc-1', link);
service.showInfoPopup(link);
service.dispose();
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 refreshes: unknown[] = [];
const refreshSub = selectionManager.refreshSelection$.subscribe((value) => refreshes.push(value));
const link = {
unitId: 'doc-1',
linkId: 'link-1',
startIndex: 4,
endIndex: 8,
};
service.showEditPopup('doc-1', link);
expect(service.editing).toEqual(link);
expect(refreshes.at(-1)).toMatchObject({ docRanges: [{ startOffset: 4, endOffset: 9 }] });
expect(attached).toMatchObject([{
range: { startOffset: 4, endOffset: 9, collapsed: false },
popup: { offset: [0, 10] },
unitId: 'doc-1',
}]);
service.showInfoPopup(link);
expect(service.showing).toEqual(link);
expect(attached.at(-1)).toMatchObject({
range: { startOffset: 4, endOffset: 9, collapsed: false },
popup: { offset: [0, 10] },
unitId: 'doc-1',
});
service.hideEditPopup();
service.hideInfoPopup();
expect(service.editing).toBeNull();
expect(service.showing).toBeNull();
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();
selectionManager.__TEST_ONLY_add([{
startOffset: 1,
endOffset: 5,
collapsed: false,
isActive: true,
segmentId: '',
}]);
const firstPopup = service.showEditPopup('doc-1', null);
const secondPopup = service.showEditPopup('doc-1', null);
expect(firstPopup).not.toBeNull();
expect(secondPopup).not.toBeNull();
expect(service.editing).toBeNull();
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]);
});
it('reuses an already visible link popup and replaces it only when the hovered link changes', () => {
const { service, attached, disposed } = createService();
const firstLink = {
unitId: 'doc-1',
linkId: 'link-1',
startIndex: 4,
endIndex: 8,
};
const secondLink = {
unitId: 'doc-1',
linkId: 'link-2',
startIndex: 0,
endIndex: 3,
};
const firstPopup = service.showInfoPopup(firstLink);
const duplicatePopup = service.showInfoPopup(firstLink);
const secondPopup = service.showInfoPopup(secondLink);
expect(firstPopup).not.toBeNull();
expect(duplicatePopup).toBeUndefined();
expect(secondPopup).not.toBeNull();
expect(attached).toHaveLength(2);
expect(service.showing).toEqual(secondLink);
expect(disposed).toEqual([1]);
});
it('keeps a click-pinned popup pinned until another link replaces it or it closes', () => {
const { service } = createService();
const firstLink = {
unitId: 'doc-1',
linkId: 'link-1',
startIndex: 4,
endIndex: 8,
};
const secondLink = {
unitId: 'doc-1',
linkId: 'link-2',
startIndex: 0,
endIndex: 3,
};
service.showInfoPopup(firstLink, { pinned: true });
expect(service.infoPopupPinned).toBe(true);
service.showInfoPopup({ ...firstLink, segmentId: '' });
expect(service.infoPopupPinned).toBe(true);
expect(service.showing).toEqual(firstLink);
service.showInfoPopup(secondLink);
expect(service.infoPopupPinned).toBe(false);
service.hideInfoPopup();
expect(service.infoPopupPinned).toBe(false);
});
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 link = {
unitId: 'doc-1',
linkId: 'link-1',
startIndex: 4,
endIndex: 8,
};
try {
service.showInfoPopup(link);
service.scheduleHideInfoPopup();
vi.advanceTimersByTime(149);
expect(service.showing).toEqual(link);
service.cancelScheduledHideInfoPopup();
vi.advanceTimersByTime(1);
expect(service.showing).toEqual(link);
service.scheduleHideInfoPopup();
vi.advanceTimersByTime(150);
expect(service.showing).toBeNull();
expect(disposed).toEqual([1]);
} finally {
service.dispose();
vi.useRealTimers();
}
});
it('does not show link information when the target document is not loaded', () => {
const { service, attached } = createService();
const popup = service.showInfoPopup({
unitId: 'missing-doc',
linkId: 'link-1',
startIndex: 0,
endIndex: 4,
});
expect(popup).toBeUndefined();
expect(service.showing).toBeNull();
expect(attached).toEqual([]);
});
it('hides the link information popup when the user clicks outside it', () => {
const { service, attached, disposed } = createService();
const link = {
unitId: 'doc-1',
linkId: 'link-1',
startIndex: 4,
endIndex: 8,
};
service.showInfoPopup(link);
const popupConfig = (attached[0] as { popup: { onClickOutside: () => void } }).popup;
popupConfig.onClickOutside();
expect(service.showing).toBeNull();
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 link = {
unitId: 'doc-1',
linkId: 'link-1',
startIndex: 4,
endIndex: 8,
};
service.showInfoPopup(link);
service.hideInfoPopupOnPointerDown();
service.showInfoPopup(link);
expect(service.showing).toBeNull();
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);
});
it('does not open an edit popup without document edit permission', () => {
const { service, attached, permissionService } = createService();
setDocumentPermissionValue(
permissionService,
'doc-1',
'doc-1',
UnitAction.Edit,
false
);
expect(service.showEditPopup('doc-1', null)).toBeNull();
expect(attached).toEqual([]);
});
});

View File

@@ -0,0 +1,305 @@
/**
* 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, ITextRangeParam } from '@univerjs/core';
import {
Disposable,
DocumentDataModel,
ICommandService,
Inject,
IPermissionService,
IUniverInstanceService,
LocaleService,
UniverInstanceType,
} from '@univerjs/core';
import { canEditDocumentTargets, DocSelectionManagerService, getDocumentEntityParentPermissionObjectIds, getDocumentEntityPermissionObjectId } from '@univerjs/docs';
import { DocCanvasPopManagerService } from '@univerjs/docs-ui';
import { IRenderManagerService } from '@univerjs/engine-render';
import { IDialogService } from '@univerjs/ui';
import { BehaviorSubject } from 'rxjs';
import { DeleteDocHyperLinkCommand } from '../commands/commands/delete-link.command';
import { DocHyperLinkEdit } from '../views/DocHyperLinkEdit';
import { DocLinkPopup } from '../views/DocLinkPopup';
export interface ILinkInfo {
unitId: string;
linkId: string;
segmentId?: string;
segmentPage?: number;
startIndex: number;
endIndex: number;
}
const INFO_POPUP_HIDE_DELAY = 150;
function isSameLinkInfo(current: ILinkInfo | null, next: ILinkInfo): boolean {
return current?.linkId === next.linkId &&
current.unitId === next.unitId &&
(current.segmentId ?? '') === (next.segmentId ?? '') &&
current.segmentPage === next.segmentPage &&
current.startIndex === next.startIndex &&
current.endIndex === next.endIndex;
}
export class DocHyperLinkPopupService extends Disposable {
private readonly _editingLink$ = new BehaviorSubject<ILinkInfo | null>(null);
private readonly _showingLink$ = new BehaviorSubject<ILinkInfo | null>(null);
readonly editingLink$ = this._editingLink$.asObservable();
readonly showingLink$ = this._showingLink$.asObservable();
private _editPopup: IDisposable | null = null;
private _editPopupUnitId: string | null = null;
private _infoPopup: IDisposable | null = null;
private _infoPopupPinned = false;
private _infoPopupHideTimer: ReturnType<typeof setTimeout> | null = null;
private _infoPopupSuppressed = false;
private _infoPopupSuppressionTimer: ReturnType<typeof setTimeout> | null = null;
constructor(
@Inject(DocCanvasPopManagerService) protected readonly _docCanvasPopupManagerService: DocCanvasPopManagerService,
@Inject(DocSelectionManagerService) private readonly _textSelectionManagerService: DocSelectionManagerService,
@IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService,
@IPermissionService private readonly _permissionService: IPermissionService,
@IRenderManagerService private readonly _renderManagerService: IRenderManagerService,
@IDialogService protected readonly _dialogService: IDialogService,
@Inject(LocaleService) protected readonly _localeService: LocaleService,
@ICommandService private readonly _commandService: ICommandService
) {
super();
this.disposeWithMe(() => {
this.cancelScheduledHideInfoPopup();
if (this._infoPopupSuppressionTimer !== null) {
clearTimeout(this._infoPopupSuppressionTimer);
}
this._editingLink$.complete();
this._showingLink$.complete();
});
this.disposeWithMe(this._permissionService.permissionPointUpdate$.subscribe(() => {
const editing = this.editing;
if (editing && !this.canEditLink(editing.unitId, editing)) {
this.hideEditPopup();
}
const showing = this.showing;
if (showing) {
this._showingLink$.next({ ...showing });
}
}));
this.disposeWithMe(this._renderManagerService.disposed$.subscribe((unitId) => {
if (this._editPopupUnitId === unitId) {
this.hideEditPopup();
}
if (this.showing?.unitId === unitId) {
this.hideInfoPopup();
}
}));
}
override dispose(): void {
this.hideEditPopup();
this.hideInfoPopup();
super.dispose();
}
get editing() {
return this._editingLink$.value;
}
get showing() {
return this._showingLink$.value;
}
get infoPopupPinned() {
return this._infoPopupPinned;
}
showEditPopup(unitId: string, linkInfo: ILinkInfo | null): IDisposable | null {
if (!this.canEditLink(unitId, linkInfo)) {
return null;
}
if (this._editPopup) {
this._editPopup.dispose();
this._editPopup = null;
this._editPopupUnitId = null;
}
this._editingLink$.next(linkInfo);
const textRanges = this._textSelectionManagerService.getTextRanges({ unitId, subUnitId: unitId });
let activeRange = textRanges?.[textRanges.length - 1];
if (linkInfo) {
const { segmentId, segmentPage, startIndex, endIndex } = linkInfo;
activeRange = {
collapsed: false,
startOffset: startIndex,
endOffset: endIndex + 1,
segmentId,
segmentPage,
};
this._textSelectionManagerService.replaceDocRanges([{
startOffset: startIndex,
endOffset: endIndex + 1,
}]);
}
if (activeRange) {
this._editPopup = this._openEditSurface(activeRange, unitId);
this._editPopupUnitId = unitId;
return this._editPopup;
}
return null;
}
hideEditPopup() {
this._editingLink$.next(null);
const popup = this._editPopup;
this._editPopup = null;
popup?.dispose();
this._editPopupUnitId = null;
}
showInfoPopup(info: ILinkInfo, options?: { pinned?: boolean }): IDisposable | null | undefined {
this.cancelScheduledHideInfoPopup();
if (this._infoPopupSuppressed) {
return;
}
const { linkId, unitId, segmentId, segmentPage, startIndex, endIndex } = info;
const isSameLink = isSameLinkInfo(this.showing, info);
if (isSameLink) {
if (options?.pinned) {
this._infoPopupPinned = true;
}
return;
}
if (this._infoPopup) {
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 });
this._infoPopup = this._openInfoSurface(info);
return this._infoPopup;
}
protected _openEditSurface(activeRange: ITextRangeParam, unitId: string): IDisposable {
return this._docCanvasPopupManagerService.attachPopupToRange(activeRange, {
componentKey: DocHyperLinkEdit.componentKey,
direction: 'bottom',
offset: [0, 10],
}, unitId);
}
protected _openInfoSurface(info: ILinkInfo, componentKey = DocLinkPopup.componentKey): IDisposable {
const { unitId, linkId, segmentId, segmentPage, startIndex, endIndex } = info;
return this._docCanvasPopupManagerService.attachPopupToRange(
{
collapsed: false,
startOffset: startIndex,
endOffset: endIndex + 1,
segmentId,
segmentPage,
},
{
componentKey,
extraProps: {
onEdit: () => {
this.hideInfoPopup();
this.showEditPopup(unitId, info);
},
onDelete: async () => {
if (await this._commandService.executeCommand(DeleteDocHyperLinkCommand.id, { unitId, linkId, segmentId })) {
this.hideInfoPopup();
}
},
},
direction: 'top-center',
multipleDirection: 'top',
offset: [0, 10],
onClickOutside: () => {
this.hideInfoPopup();
},
},
unitId
);
}
hideInfoPopup() {
this.cancelScheduledHideInfoPopup();
this._infoPopupPinned = false;
this._showingLink$.next(null);
const popup = this._infoPopup;
this._infoPopup = null;
popup?.dispose();
}
scheduleHideInfoPopup() {
if (this._infoPopupPinned || !this.showing) {
return;
}
this.cancelScheduledHideInfoPopup();
this._infoPopupHideTimer = setTimeout(() => {
this._infoPopupHideTimer = null;
if (!this._infoPopupPinned) {
this.hideInfoPopup();
}
}, INFO_POPUP_HIDE_DELAY);
}
cancelScheduledHideInfoPopup() {
if (this._infoPopupHideTimer !== null) {
clearTimeout(this._infoPopupHideTimer);
this._infoPopupHideTimer = null;
}
}
hideInfoPopupOnPointerDown() {
this._infoPopupSuppressed = true;
if (this._infoPopupSuppressionTimer !== null) {
clearTimeout(this._infoPopupSuppressionTimer);
}
this.hideInfoPopup();
this._infoPopupSuppressionTimer = setTimeout(() => {
this._infoPopupSuppressed = false;
this._infoPopupSuppressionTimer = null;
}, 0);
}
canEditLink(unitId: string, linkInfo: ILinkInfo | null): boolean {
const document = this._univerInstanceService.getUnit(unitId, UniverInstanceType.UNIVER_DOC);
if (!(document instanceof DocumentDataModel)) {
return false;
}
if (!linkInfo) {
return canEditDocumentTargets(this._permissionService, unitId, []);
}
const segmentId = linkInfo.segmentId ?? '';
return canEditDocumentTargets(this._permissionService, unitId, [
...getDocumentEntityParentPermissionObjectIds(document, segmentId, 'custom-range', linkInfo.linkId),
getDocumentEntityPermissionObjectId(segmentId, 'custom-range', linkInfo.linkId),
]);
}
}

View File

@@ -0,0 +1,46 @@
/**
* 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, ITextRangeParam } from '@univerjs/core';
import type { ILinkInfo } from '../hyper-link-popup.service';
import { MOBILE_DOC_ELEMENT_MENU } from '@univerjs/docs-ui';
import { MobileDocHyperLinkEdit } from '../../views/MobileDocHyperLinkEdit';
import { MobileDocLinkPopup } from '../../views/MobileDocLinkPopup';
import { DocHyperLinkPopupService } from '../hyper-link-popup.service';
export class MobileDocHyperLinkPopupService extends DocHyperLinkPopupService {
protected override _openEditSurface(_activeRange: ITextRangeParam, _unitId: string): IDisposable {
return this._dialogService.open({
id: 'doc-mobile-hyper-link-editor',
title: { title: this._localeService.t('docs-hyper-link-ui.menu.tooltip') },
children: { label: MobileDocHyperLinkEdit.componentKey },
maskClosable: false,
onClose: () => this.hideEditPopup(),
});
}
protected override _openInfoSurface(info: ILinkInfo): IDisposable {
if (this.canEditLink(info.unitId, info)) {
return super._openInfoSurface(info, MOBILE_DOC_ELEMENT_MENU);
}
return this._dialogService.open({
id: 'doc-mobile-hyper-link-viewer',
title: { title: this._localeService.t('docs-hyper-link-ui.menu.tooltip') },
children: { label: MobileDocLinkPopup.componentKey },
onClose: () => this.hideInfoPopup(),
});
}
}

View File

@@ -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 const DOC_HYPER_LINK_UI_PLUGIN = 'DOC_HYPER_LINK_UI_PLUGIN';

View File

@@ -0,0 +1,103 @@
/**
* 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 { LocaleKey } from '../locale/types';
import { LocaleService } from '@univerjs/core';
import { borderClassName, Button, clsx, FormLayout, Input } from '@univerjs/design';
import { KeyCode, useDependency } from '@univerjs/ui';
import { useDocHyperLinkEdit } from './hyper-link-edit/use-doc-hyper-link-edit';
import { isBlankInput } from './hyper-link-edit/utils';
export const DocHyperLinkEdit = () => {
const localeService = useDependency(LocaleService);
const {
doc,
editing,
handleCancel,
handleConfirm,
isLegal,
label,
link,
setLabel,
setLink,
showError,
} = useDocHyperLinkEdit();
if (!doc) {
return;
}
return (
<div
className={clsx(`
univer-box-border univer-w-[328px] univer-rounded-xl univer-bg-gray-0 univer-px-6 univer-py-5
univer-shadow
dark:!univer-bg-gray-900
`, borderClassName)}
>
<div>
{editing
? (
<FormLayout
label={localeService.t<LocaleKey>('docs-hyper-link-ui.edit.label')}
error={showError && isBlankInput(label) ? localeService.t<LocaleKey>('docs-hyper-link-ui.edit.labelError') : ''}
>
<Input
value={label}
onChange={setLabel}
autoFocus
onKeyDown={(event) => {
if (event.keyCode === KeyCode.ENTER) {
handleConfirm();
}
}}
/>
</FormLayout>
)
: null}
<FormLayout
label={localeService.t<LocaleKey>('docs-hyper-link-ui.edit.address')}
error={showError && !isLegal ? localeService.t<LocaleKey>('docs-hyper-link-ui.edit.addressError') : ''}
>
<Input
value={link}
onChange={setLink}
autoFocus
onKeyDown={(event) => {
if (event.keyCode === KeyCode.ENTER) {
handleConfirm();
}
}}
/>
</FormLayout>
</div>
<div className="univer-flex univer-justify-end univer-gap-3">
<Button onClick={handleCancel}>
{localeService.t<LocaleKey>('docs-hyper-link-ui.edit.cancel')}
</Button>
<Button
variant="primary"
disabled={isBlankInput(link)}
onClick={handleConfirm}
>
{localeService.t<LocaleKey>('docs-hyper-link-ui.edit.confirm')}
</Button>
</div>
</div>
);
};
DocHyperLinkEdit.componentKey = 'docs-hyper-link-edit';

View File

@@ -0,0 +1,166 @@
/**
* 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 { LocaleKey } from '../locale/types';
import {
CustomRangeType,
DocumentDataModel,
ICommandService,
IPermissionService,
IUniverInstanceService,
LocaleService,
UniverInstanceType,
} from '@univerjs/core';
import { borderClassName, Button, clsx, MessageType, Tooltip } from '@univerjs/design';
import { getDocumentPermissionValue } from '@univerjs/docs';
import { CopyIcon, LinkIcon, UnlinkIcon, WriteIcon } from '@univerjs/icons';
import { UnitAction } from '@univerjs/protocol';
import { IMessageService, useDependency, useObservable } from '@univerjs/ui';
import { DeleteDocHyperLinkCommand } from '../commands/commands/delete-link.command';
import { ShowDocHyperLinkEditPopupOperation } from '../commands/operations/popup.operation';
import { DocHyperLinkPopupService } from '../services/hyper-link-popup.service';
export const DocLinkPopup = () => {
const hyperLinkService = useDependency(DocHyperLinkPopupService);
const commandService = useDependency(ICommandService);
const messageService = useDependency(IMessageService);
const localeService = useDependency(LocaleService);
const currentPopup = useObservable(hyperLinkService.showingLink$, hyperLinkService.showing);
const univerInstanceService = useDependency(IUniverInstanceService);
const permissionService = useDependency(IPermissionService);
if (!currentPopup) {
return null;
}
const { unitId, linkId, segmentId, startIndex, endIndex } = currentPopup;
const doc = univerInstanceService.getUnit(unitId, UniverInstanceType.UNIVER_DOC);
const body = doc instanceof DocumentDataModel
? doc.getSelfOrHeaderFooterModel(segmentId)?.getBody()
: undefined;
const link = body?.customRanges?.find((range) => range.rangeId === linkId && range.rangeType === CustomRangeType.HYPERLINK && range.startIndex === startIndex && range.endIndex === endIndex);
if (!link) {
return null;
}
const url = link.properties?.url;
const canEdit = hyperLinkService.canEditLink(unitId, currentPopup);
const canCopy = getDocumentPermissionValue(permissionService, unitId, unitId, UnitAction.Copy);
return (
<div
className={clsx(`
univer-box-border univer-flex univer-max-w-80 univer-items-center univer-justify-between
univer-overflow-hidden univer-rounded-lg univer-bg-gray-0 univer-p-3 univer-shadow
dark:!univer-bg-gray-900
`, borderClassName)}
onPointerEnter={() => {
hyperLinkService.cancelScheduledHideInfoPopup();
}}
onPointerLeave={() => {
hyperLinkService.scheduleHideInfoPopup();
}}
onClick={() => {
hyperLinkService.hideInfoPopup();
}}
>
<div
className={`
univer-flex univer-h-6 univer-flex-1 univer-cursor-pointer univer-items-center univer-truncate
univer-text-sm univer-leading-5 univer-text-primary-500
`}
onClick={() => window.open(url, undefined, 'noopener noreferrer')}
>
<div
className={`
univer-mr-2 univer-flex univer-size-5 univer-flex-[0_0_auto] univer-items-center
univer-justify-center univer-text-base univer-text-gray-900
dark:!univer-text-gray-0
`}
>
<LinkIcon />
</div>
<Tooltip showIfEllipsis title={url}>
<span className="univer-flex-1 univer-truncate">{url}</span>
</Tooltip>
</div>
<div className="univer-flex univer-h-6 univer-flex-[0_0_auto] univer-items-center univer-justify-center">
<Button
type="button"
variant="text"
size="small"
aria-label={localeService.t<LocaleKey>('docs-hyper-link-ui.info.copy')}
className="
univer-ml-2 !univer-flex univer-size-6 !univer-rounded !univer-border-0 !univer-bg-transparent
!univer-p-0 !univer-text-base !univer-font-normal
hover:!univer-bg-transparent
active:!univer-bg-transparent
disabled:!univer-pointer-events-auto disabled:!univer-opacity-40
"
disabled={!canCopy}
onClick={() => {
navigator.clipboard.writeText(url);
messageService.show({
content: localeService.t<LocaleKey>('docs-hyper-link-ui.info.coped'),
type: MessageType.Info,
});
}}
>
<Tooltip placement="bottom" title={localeService.t<LocaleKey>('docs-hyper-link-ui.info.copy')}>
<CopyIcon />
</Tooltip>
</Button>
{canEdit && (
<>
<div
className={`
univer-ml-2 univer-flex univer-size-6 univer-cursor-pointer univer-items-center
univer-justify-center univer-rounded univer-text-base
`}
onClick={() => {
commandService.executeCommand(ShowDocHyperLinkEditPopupOperation.id, {
link: currentPopup,
});
}}
>
<Tooltip placement="bottom" title={localeService.t<LocaleKey>('docs-hyper-link-ui.info.edit')}>
<WriteIcon />
</Tooltip>
</div>
<div
className={`
univer-ml-2 univer-flex univer-size-6 univer-cursor-pointer univer-items-center
univer-justify-center univer-rounded univer-text-base
`}
onClick={() => {
commandService.executeCommand(DeleteDocHyperLinkCommand.id, {
unitId,
linkId: link.rangeId,
segmentId,
});
}}
>
<Tooltip placement="bottom" title={localeService.t<LocaleKey>('docs-hyper-link-ui.info.cancel')}>
<UnlinkIcon />
</Tooltip>
</div>
</>
)}
</div>
</div>
);
};
DocLinkPopup.componentKey = 'univer.doc.link-info-popup';

View File

@@ -0,0 +1,103 @@
/**
* 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 { LocaleKey } from '../locale/types';
import { LocaleService } from '@univerjs/core';
import { Button, FormLayout, Input } from '@univerjs/design';
import { KeyCode, useDependency } from '@univerjs/ui';
import { useDocHyperLinkEdit } from './hyper-link-edit/use-doc-hyper-link-edit';
import { isBlankInput } from './hyper-link-edit/utils';
export function MobileDocHyperLinkEdit() {
const localeService = useDependency(LocaleService);
const {
doc,
editing,
handleCancel,
handleConfirm,
isLegal,
label,
link,
setLabel,
setLink,
showError,
} = useDocHyperLinkEdit();
if (!doc) {
return;
}
return (
<div
className="
univer-box-border univer-w-full univer-bg-gray-0 univer-py-2
dark:!univer-bg-gray-900
"
>
<div>
{editing
? (
<FormLayout
label={localeService.t<LocaleKey>('docs-hyper-link-ui.edit.label')}
error={showError && isBlankInput(label) ? localeService.t<LocaleKey>('docs-hyper-link-ui.edit.labelError') : ''}
>
<Input
value={label}
onChange={setLabel}
autoFocus
onKeyDown={(event) => {
if (event.keyCode === KeyCode.ENTER) {
handleConfirm();
}
}}
/>
</FormLayout>
)
: null}
<FormLayout
label={localeService.t<LocaleKey>('docs-hyper-link-ui.edit.address')}
error={showError && !isLegal ? localeService.t<LocaleKey>('docs-hyper-link-ui.edit.addressError') : ''}
>
<Input
value={link}
onChange={setLink}
autoFocus={!editing}
onKeyDown={(event) => {
if (event.keyCode === KeyCode.ENTER) {
handleConfirm();
}
}}
/>
</FormLayout>
</div>
<div className="univer-mt-5 univer-flex univer-gap-3">
<Button className="univer-h-12 univer-flex-1" onClick={handleCancel}>
{localeService.t<LocaleKey>('docs-hyper-link-ui.edit.cancel')}
</Button>
<Button
variant="primary"
className="univer-h-12 univer-flex-1"
disabled={isBlankInput(link)}
onClick={handleConfirm}
>
{localeService.t<LocaleKey>('docs-hyper-link-ui.edit.confirm')}
</Button>
</div>
</div>
);
}
MobileDocHyperLinkEdit.componentKey = 'univer.doc.mobile-hyper-link-edit';

View File

@@ -0,0 +1,115 @@
/**
* 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 { LocaleKey } from '../locale/types';
import {
CustomRangeType,
DocumentDataModel,
ICommandService,
IPermissionService,
IUniverInstanceService,
LocaleService,
UniverInstanceType,
} from '@univerjs/core';
import { MessageType, MobileActionRow } from '@univerjs/design';
import { getDocumentPermissionValue } from '@univerjs/docs';
import { UnitAction } from '@univerjs/protocol';
import { IMessageService, useDependency, useObservable } from '@univerjs/ui';
import { DeleteDocHyperLinkCommand } from '../commands/commands/delete-link.command';
import { ShowDocHyperLinkEditPopupOperation } from '../commands/operations/popup.operation';
import { DocHyperLinkPopupService } from '../services/hyper-link-popup.service';
export function MobileDocLinkPopup() {
const commandService = useDependency(ICommandService);
const hyperLinkService = useDependency(DocHyperLinkPopupService);
const localeService = useDependency(LocaleService);
const messageService = useDependency(IMessageService);
const permissionService = useDependency(IPermissionService);
const univerInstanceService = useDependency(IUniverInstanceService);
const currentPopup = useObservable(hyperLinkService.showingLink$, hyperLinkService.showing);
if (!currentPopup) {
return null;
}
const { unitId, linkId, segmentId, startIndex, endIndex } = currentPopup;
const document = univerInstanceService.getUnit(unitId, UniverInstanceType.UNIVER_DOC);
const body = document instanceof DocumentDataModel
? document.getSelfOrHeaderFooterModel(segmentId)?.getBody()
: undefined;
const link = body?.customRanges?.find((range) => range.rangeId === linkId
&& range.rangeType === CustomRangeType.HYPERLINK
&& range.startIndex === startIndex
&& range.endIndex === endIndex);
if (!link) {
return null;
}
const url = link.properties?.url;
const canEdit = hyperLinkService.canEditLink(unitId, currentPopup);
const canCopy = getDocumentPermissionValue(permissionService, unitId, unitId, UnitAction.Copy);
return (
<div className="univer-flex univer-flex-col univer-gap-2 univer-py-2">
<MobileActionRow
title={<span className="univer-truncate univer-text-primary-600">{url}</span>}
aria-label={url}
variant="subtle"
onClick={() => window.open(url, undefined, 'noopener noreferrer')}
/>
{canCopy && (
<MobileActionRow
title={localeService.t<LocaleKey>('docs-hyper-link-ui.info.copy')}
aria-label={localeService.t<LocaleKey>('docs-hyper-link-ui.info.copy')}
variant="subtle"
onClick={() => navigator.clipboard.writeText(url).then(() => {
messageService.show({
content: localeService.t<LocaleKey>('docs-hyper-link-ui.info.coped'),
type: MessageType.Info,
});
}).catch(() => undefined)}
/>
)}
{canEdit && (
<>
<MobileActionRow
title={localeService.t<LocaleKey>('docs-hyper-link-ui.info.edit')}
aria-label={localeService.t<LocaleKey>('docs-hyper-link-ui.info.edit')}
variant="subtle"
onClick={() => {
commandService.executeCommand(ShowDocHyperLinkEditPopupOperation.id, {
link: currentPopup,
}).catch(() => undefined);
}}
/>
<MobileActionRow
title={localeService.t<LocaleKey>('docs-hyper-link-ui.info.cancel')}
aria-label={localeService.t<LocaleKey>('docs-hyper-link-ui.info.cancel')}
variant="subtle"
onClick={() => {
commandService.executeCommand(DeleteDocHyperLinkCommand.id, {
unitId,
linkId: link.rangeId,
segmentId,
}).catch(() => undefined);
}}
/>
</>
)}
</div>
);
}
MobileDocLinkPopup.componentKey = 'univer.doc.mobile-link-info-popup';

View File

@@ -0,0 +1,406 @@
/**
* 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 { DocumentDataModel, IDocumentData } from '@univerjs/core';
import type { Root } from 'react-dom/client';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import {
CustomRangeType,
ICommandService,
IPermissionService,
IUniverInstanceService,
LocaleService,
LocaleType,
toDisposable,
Univer,
UniverInstanceType,
} from '@univerjs/core';
import { DocSelectionManagerService, DocStateEmitService, RichTextEditingMutation, setDocumentPermissionValue } from '@univerjs/docs';
import { DocCanvasPopManagerService } from '@univerjs/docs-ui';
import { IRenderManagerService, RenderManagerService } from '@univerjs/engine-render';
import { UnitAction } from '@univerjs/protocol';
import { IDialogService, IMessageService, RediContext } from '@univerjs/ui';
import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { of } from 'rxjs';
import { afterEach, describe, expect, it } from 'vitest';
import { AddDocHyperLinkCommand } from '../../commands/commands/add-link.command';
import { DeleteDocHyperLinkCommand } from '../../commands/commands/delete-link.command';
import { UpdateDocHyperLinkCommand } from '../../commands/commands/update-link.command';
import { ShowDocHyperLinkEditPopupOperation } from '../../commands/operations/popup.operation';
import { DocHyperLinkPopupService } from '../../services/hyper-link-popup.service';
import { DocHyperLinkEdit } from '../DocHyperLinkEdit';
import { DocLinkPopup } from '../DocLinkPopup';
import { MobileDocHyperLinkEdit } from '../MobileDocHyperLinkEdit';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const UNIT_ID = 'doc-link-popup-doc';
class TestDocCanvasPopManagerService {
disposeCount = 0;
attachPopupToRange() {
return toDisposable(() => {
this.disposeCount += 1;
});
}
}
class TestMessageService {
readonly messages: unknown[] = [];
show(message: unknown) {
this.messages.push(message);
}
}
function setInputText(input: HTMLInputElement, value: string) {
const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set;
valueSetter?.call(input, value);
input.dispatchEvent(new Event('input', { bubbles: true }));
}
function createDocData(): IDocumentData {
return {
id: UNIT_ID,
body: {
dataStream: 'Hello world\r\n',
customRanges: [{
startIndex: 6,
endIndex: 10,
rangeId: 'existing-link',
rangeType: CustomRangeType.HYPERLINK,
properties: {
url: 'https://univer.ai',
},
}],
},
documentStyle: {
pageSize: {
width: 594.3,
height: 840.51,
},
marginTop: 72,
marginBottom: 72,
marginRight: 90,
marginLeft: 90,
},
};
}
function createPopupTestBed() {
const univer = new Univer();
const injector = univer.__getInjector();
injector.add([DocSelectionManagerService]);
injector.add([DocStateEmitService]);
injector.add([DocCanvasPopManagerService, { useClass: TestDocCanvasPopManagerService as never }]);
injector.add([IRenderManagerService, { useClass: RenderManagerService }]);
injector.add([IMessageService, { useClass: TestMessageService as never }]);
injector.add([IDialogService, {
useValue: {
close: () => {},
closeAll: () => {},
getDialogs$: () => of([]),
open: () => toDisposable(() => {}),
},
}]);
injector.add([DocHyperLinkPopupService]);
const doc = univer.createUnit<IDocumentData, DocumentDataModel>(UniverInstanceType.UNIVER_DOC, createDocData());
injector.get(IUniverInstanceService).focusUnit(UNIT_ID);
injector.get(LocaleService).load({
[LocaleType.EN_US]: {
'docs-hyper-link-ui': {
info: {
copy: 'Copy',
edit: 'Edit',
cancel: 'Remove link',
coped: 'Copied',
},
edit: {
label: 'Label',
labelError: 'Label is required',
address: 'Address',
addressError: 'Address is invalid',
cancel: 'Cancel',
confirm: 'Confirm',
},
},
},
});
injector.get(LocaleService).setLocale(LocaleType.EN_US);
const commandService = injector.get(ICommandService);
commandService.registerCommand(AddDocHyperLinkCommand);
commandService.registerCommand(UpdateDocHyperLinkCommand);
commandService.registerCommand(DeleteDocHyperLinkCommand);
commandService.registerCommand(ShowDocHyperLinkEditPopupOperation);
commandService.registerCommand(RichTextEditingMutation);
return {
univer,
injector,
commandService,
popupService: injector.get(DocHyperLinkPopupService),
doc,
};
}
function showExistingLink(testBed: ReturnType<typeof createPopupTestBed>) {
testBed.popupService.showInfoPopup({
unitId: UNIT_ID,
linkId: 'existing-link',
startIndex: 6,
endIndex: 10,
});
}
function renderPopup(root: Root, container: HTMLDivElement, testBed: ReturnType<typeof createPopupTestBed>) {
act(() => {
root.render(
<RediContext.Provider value={{ injector: testBed.injector }}>
<DocLinkPopup />
</RediContext.Provider>
);
});
const actions = Array.from(container.querySelectorAll('.univer-ml-2')) as HTMLElement[];
return {
edit: actions[1],
remove: actions[2],
};
}
function renderEditPopup(root: Root, testBed: ReturnType<typeof createPopupTestBed>, mobile = false) {
act(() => {
root.render(
<RediContext.Provider value={{ injector: testBed.injector }}>
{mobile ? <MobileDocHyperLinkEdit /> : <DocHyperLinkEdit />}
</RediContext.Provider>
);
});
}
describe('DocLinkPopup', () => {
let root: Root | undefined;
let container: HTMLDivElement | undefined;
let currentTestBed: ReturnType<typeof createPopupTestBed> | undefined;
afterEach(() => {
act(() => {
root?.unmount();
});
container?.remove();
currentTestBed?.univer.dispose();
root = undefined;
container = undefined;
currentTestBed = undefined;
});
it('keeps desktop and mobile hyperlink edit components independent', () => {
const desktopSource = readFileSync(resolve(process.cwd(), 'src/views/DocHyperLinkEdit.tsx'), 'utf8');
const mobileSource = readFileSync(resolve(process.cwd(), 'src/views/MobileDocHyperLinkEdit.tsx'), 'utf8');
expect(desktopSource).not.toContain('props.mobile');
expect(mobileSource).not.toContain("from './DocHyperLinkEdit'");
});
it('opens the hyperlink edit popup for the currently displayed document link', async () => {
currentTestBed = createPopupTestBed();
showExistingLink(currentTestBed);
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
const executedCommands: Array<{ id: string; params: unknown }> = [];
currentTestBed.commandService.onCommandExecuted((command) => {
executedCommands.push({ id: command.id, params: command.params });
});
const actions = renderPopup(root, container, currentTestBed);
await act(async () => {
actions.edit.click();
await Promise.resolve();
});
expect(executedCommands).toContainEqual({
id: ShowDocHyperLinkEditPopupOperation.id,
params: {
link: {
unitId: UNIT_ID,
linkId: 'existing-link',
segmentId: undefined,
segmentPage: undefined,
startIndex: 6,
endIndex: 10,
},
},
});
});
it('disables the copy action when document copy permission is denied', () => {
currentTestBed = createPopupTestBed();
setDocumentPermissionValue(
currentTestBed.injector.get(IPermissionService),
UNIT_ID,
UNIT_ID,
UnitAction.Copy,
false
);
showExistingLink(currentTestBed);
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
renderPopup(root, container, currentTestBed);
const copy = container.querySelector<HTMLButtonElement>('button[aria-label="Copy"]');
expect(copy).toBeInstanceOf(HTMLButtonElement);
expect(copy?.dataset.uComp).toBe('button');
expect(copy?.disabled).toBe(true);
});
it('removes the displayed hyperlink while keeping the document text', async () => {
currentTestBed = createPopupTestBed();
showExistingLink(currentTestBed);
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
const actions = renderPopup(root, container, currentTestBed);
await act(async () => {
actions.remove.click();
await Promise.resolve();
});
const body = currentTestBed.doc.getBody();
expect(body?.dataStream).toBe('Hello world\r\n');
expect(body?.customRanges?.some((range) => range.rangeId === 'existing-link')).toBe(false);
});
it.each([false, true])('updates an existing document hyperlink from the edit form (mobile: %s)', async (mobile) => {
currentTestBed = createPopupTestBed();
const selectionManager = currentTestBed.injector.get(DocSelectionManagerService);
selectionManager.__TEST_ONLY_setCurrentSelection({ unitId: UNIT_ID, subUnitId: UNIT_ID });
selectionManager.__TEST_ONLY_add([{
startOffset: 6,
endOffset: 11,
collapsed: false,
isActive: true,
segmentId: '',
style: null as never,
}]);
currentTestBed.popupService.showEditPopup(UNIT_ID, {
unitId: UNIT_ID,
linkId: 'existing-link',
segmentId: '',
startIndex: 6,
endIndex: 10,
});
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
const focusedInputs: EventTarget[] = [];
container.addEventListener('focusin', (event) => {
if (event.target instanceof HTMLInputElement) {
focusedInputs.push(event.target);
}
});
renderEditPopup(root, currentTestBed, mobile);
const [labelInput, linkInput] = Array.from(container.querySelectorAll('input')) as HTMLInputElement[];
expect(labelInput.value).toBe('world');
expect(linkInput.value).toBe('https://univer.ai');
if (mobile) {
expect(focusedInputs).toEqual([labelInput]);
}
await act(async () => {
setInputText(labelInput, 'docs');
setInputText(linkInput, 'docs.univer.ai');
await Promise.resolve();
});
const confirm = Array.from(container.querySelectorAll('button, [data-u-comp="button"]'))
.find((button) => button.textContent === 'Confirm') as HTMLElement;
expect(confirm).toBeTruthy();
await act(async () => {
confirm.click();
await Promise.resolve();
});
const body = currentTestBed.doc.getBody();
const updatedLink = body?.customRanges?.find((range) => range.rangeId === 'existing-link');
expect(body?.dataStream).toBe('Hello docs\r\n');
expect(updatedLink?.properties).toEqual({
url: 'https://docs.univer.ai',
});
expect(currentTestBed.popupService.editing).toBeNull();
});
it('adds a document hyperlink from selected text and normalizes a bare URL', async () => {
currentTestBed = createPopupTestBed();
const selectionManager = currentTestBed.injector.get(DocSelectionManagerService);
const popManager = currentTestBed.injector.get(DocCanvasPopManagerService) as unknown as TestDocCanvasPopManagerService;
selectionManager.__TEST_ONLY_setCurrentSelection({ unitId: UNIT_ID, subUnitId: UNIT_ID });
selectionManager.__TEST_ONLY_add([{
startOffset: 0,
endOffset: 5,
collapsed: false,
isActive: true,
segmentId: '',
style: null as never,
}]);
currentTestBed.popupService.showEditPopup(UNIT_ID, null);
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
renderEditPopup(root, currentTestBed);
const [linkInput] = Array.from(container.querySelectorAll('input')) as HTMLInputElement[];
expect(linkInput.value).toBe('');
await act(async () => {
setInputText(linkInput, 'docs.univer.ai');
await Promise.resolve();
});
const confirm = Array.from(container.querySelectorAll('button, [data-u-comp="button"]'))
.find((button) => button.textContent === 'Confirm') as HTMLElement;
await act(async () => {
confirm.click();
await Promise.resolve();
});
const body = currentTestBed.doc.getBody();
const addedLink = body?.customRanges?.find((range) => range.properties?.url === 'https://docs.univer.ai');
expect(body?.dataStream).toBe('Hello world\r\n');
expect(addedLink?.startIndex).toBe(0);
expect(addedLink?.endIndex).toBe(4);
expect(currentTestBed.popupService.editing).toBeNull();
expect(popManager.disposeCount).toBe(1);
});
});

View File

@@ -0,0 +1,27 @@
/**
* 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 { isBlankInput } from '../utils';
describe('DocHyperLinkEdit utils', () => {
it('treats whitespace-only input as blank', () => {
expect(isBlankInput('')).toBe(true);
expect(isBlankInput(' ')).toBe(true);
expect(isBlankInput('\n\t')).toBe(true);
expect(isBlankInput(' link ')).toBe(false);
});
});

View File

@@ -0,0 +1,134 @@
/**
* 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 { DocumentDataModel } from '@univerjs/core';
import {
BuildTextUtils,
getBodySlice,
ICommandService,
IUniverInstanceService,
Tools,
UniverInstanceType,
} from '@univerjs/core';
import { DocSelectionManagerService } from '@univerjs/docs';
import { useDependency, useObservable } from '@univerjs/ui';
import { useEffect, useState } from 'react';
import { AddDocHyperLinkCommand } from '../../commands/commands/add-link.command';
import { UpdateDocHyperLinkCommand } from '../../commands/commands/update-link.command';
import { DocHyperLinkPopupService } from '../../services/hyper-link-popup.service';
import { isBlankInput } from './utils';
function hasProtocol(url: string): boolean {
return /^[a-zA-Z]+:\/\//.test(url);
}
function isEmail(url: string): boolean {
return /^[\w.-]+@([\w-]+\.)+[\w-]{2,4}$/.test(url);
}
function transformUrl(url: string): string {
return hasProtocol(url) ? url : isEmail(url) ? `mailto://${url}` : `https://${url}`;
}
// eslint-disable-next-line max-lines-per-function
export function useDocHyperLinkEdit() {
const hyperLinkService = useDependency(DocHyperLinkPopupService);
const editing = useObservable(hyperLinkService.editingLink$, hyperLinkService.editing);
const commandService = useDependency(ICommandService);
const univerInstanceService = useDependency(IUniverInstanceService);
const docSelectionManagerService = useDependency(DocSelectionManagerService);
const [link, setLink] = useState('');
const [label, setLabel] = useState('');
const [showError, setShowError] = useState(false);
const isLegal = Tools.isLegalUrl(link);
const doc = editing
? univerInstanceService.getUnit<DocumentDataModel>(editing.unitId, UniverInstanceType.UNIVER_DOC)
: univerInstanceService.getCurrentUnitOfType<DocumentDataModel>(UniverInstanceType.UNIVER_DOC);
// eslint-disable-next-line complexity
useEffect(() => {
const activeRange = docSelectionManagerService.getActiveTextRange();
if (!activeRange) {
return;
}
if (editing) {
const body = doc?.getSelfOrHeaderFooterModel(editing.segmentId)?.getBody();
const matchedRange = body?.customRanges?.find((range) =>
editing.linkId === range.rangeId &&
range.startIndex === editing.startIndex &&
range.endIndex === editing.endIndex
);
if (doc && matchedRange) {
// Selection changes are the external source of truth for these controlled inputs.
// eslint-disable-next-line react/set-state-in-effect
setLink(matchedRange.properties?.url ?? '');
// eslint-disable-next-line react/set-state-in-effect
setLabel(BuildTextUtils.transform.getPlainText(getBodySlice(body!, matchedRange.startIndex, matchedRange.endIndex + 1).dataStream));
}
return;
}
const body = doc?.getSelfOrHeaderFooterModel(activeRange.segmentId)?.getBody();
const selection = body ? activeRange : null;
const matchedRange = selection && BuildTextUtils.customRange.getCustomRangesInterestsWithSelection(selection, body?.customRanges ?? [])?.[0];
if (doc && matchedRange) {
// eslint-disable-next-line react/set-state-in-effect
setLink(matchedRange.properties?.url ?? '');
}
}, [doc, docSelectionManagerService, editing]);
const handleCancel = () => hyperLinkService.hideEditPopup();
const handleConfirm = () => {
setShowError(true);
if (!isLegal || !doc) {
return;
}
const payload = transformUrl(link);
if (editing) {
if (isBlankInput(label)) {
return;
}
commandService.executeCommand(UpdateDocHyperLinkCommand.id, {
unitId: doc.getUnitId(),
payload,
linkId: editing.linkId,
label,
segmentId: editing.segmentId,
}).catch(() => undefined);
} else {
commandService.executeCommand(AddDocHyperLinkCommand.id, {
unitId: doc.getUnitId(),
payload,
}).catch(() => undefined);
}
hyperLinkService.hideEditPopup();
};
return {
doc,
editing,
handleCancel,
handleConfirm,
isLegal,
label,
link,
setLabel,
setLink,
showError,
};
}

View File

@@ -0,0 +1,19 @@
/**
* 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 function isBlankInput(value: string) {
return value.trim().length === 0;
}

View File

@@ -0,0 +1,27 @@
/**
* 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 { Config } from 'tailwindcss';
import preset from '@univerjs-infra/shared/tailwind';
const config: Config = {
presets: [preset],
content: [
'./src/**/*.{js,ts,jsx,tsx}',
],
};
export default config;

View File

@@ -0,0 +1,8 @@
{
"extends": "@univerjs-infra/shared/tsconfigs/base",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": ["src"]
}

View File

@@ -0,0 +1,18 @@
{
"extends": "@univerjs-infra/shared/tsconfigs/node",
"compilerOptions": {
"rootDir": "src",
"declaration": true,
"emitDeclarationOnly": true,
"noEmit": false,
"outDir": "lib/types"
},
"include": [
"src"
],
"exclude": [
"src/**/__tests__/**",
"src/**/*.spec.ts",
"src/**/*.test.ts"
]
}

View File

@@ -0,0 +1,3 @@
import createConfig from '@univerjs-infra/shared/vitest';
export default createConfig();