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
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:
45
packages/docs-hyper-link-ui/README.md
Normal file
45
packages/docs-hyper-link-ui/README.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# @univerjs/docs-hyper-link-ui
|
||||
|
||||
[](https://npmjs.com/package/@univerjs/docs-hyper-link-ui)
|
||||
[](https://npmjs.com/package/@univerjs/docs-hyper-link-ui)
|
||||
[](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)
|
||||
96
packages/docs-hyper-link-ui/package.json
Normal file
96
packages/docs-hyper-link-ui/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
1
packages/docs-hyper-link-ui/postcss.config.mjs
Normal file
1
packages/docs-hyper-link-ui/postcss.config.mjs
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from '@univerjs-infra/shared/postcss';
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
27
packages/docs-hyper-link-ui/src/config/config.ts
Normal file
27
packages/docs-hyper-link-ui/src/config/config.ts
Normal 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 = {};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
67
packages/docs-hyper-link-ui/src/controllers/ui.controller.ts
Normal file
67
packages/docs-hyper-link-ui/src/controllers/ui.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
2
packages/docs-hyper-link-ui/src/global.css
Normal file
2
packages/docs-hyper-link-ui/src/global.css
Normal file
@@ -0,0 +1,2 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
22
packages/docs-hyper-link-ui/src/index.ts
Normal file
22
packages/docs-hyper-link-ui/src/index.ts
Normal 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';
|
||||
41
packages/docs-hyper-link-ui/src/locale/ar-SA.ts
Normal file
41
packages/docs-hyper-link-ui/src/locale/ar-SA.ts
Normal 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;
|
||||
41
packages/docs-hyper-link-ui/src/locale/ca-ES.ts
Normal file
41
packages/docs-hyper-link-ui/src/locale/ca-ES.ts
Normal 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;
|
||||
41
packages/docs-hyper-link-ui/src/locale/de-DE.ts
Normal file
41
packages/docs-hyper-link-ui/src/locale/de-DE.ts
Normal 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;
|
||||
39
packages/docs-hyper-link-ui/src/locale/en-US.ts
Normal file
39
packages/docs-hyper-link-ui/src/locale/en-US.ts
Normal 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;
|
||||
41
packages/docs-hyper-link-ui/src/locale/es-ES.ts
Normal file
41
packages/docs-hyper-link-ui/src/locale/es-ES.ts
Normal 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;
|
||||
41
packages/docs-hyper-link-ui/src/locale/fa-IR.ts
Normal file
41
packages/docs-hyper-link-ui/src/locale/fa-IR.ts
Normal 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;
|
||||
41
packages/docs-hyper-link-ui/src/locale/fr-FR.ts
Normal file
41
packages/docs-hyper-link-ui/src/locale/fr-FR.ts
Normal 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;
|
||||
41
packages/docs-hyper-link-ui/src/locale/id-ID.ts
Normal file
41
packages/docs-hyper-link-ui/src/locale/id-ID.ts
Normal 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;
|
||||
41
packages/docs-hyper-link-ui/src/locale/it-IT.ts
Normal file
41
packages/docs-hyper-link-ui/src/locale/it-IT.ts
Normal 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;
|
||||
41
packages/docs-hyper-link-ui/src/locale/ja-JP.ts
Normal file
41
packages/docs-hyper-link-ui/src/locale/ja-JP.ts
Normal 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;
|
||||
41
packages/docs-hyper-link-ui/src/locale/ko-KR.ts
Normal file
41
packages/docs-hyper-link-ui/src/locale/ko-KR.ts
Normal 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;
|
||||
41
packages/docs-hyper-link-ui/src/locale/pl-PL.ts
Normal file
41
packages/docs-hyper-link-ui/src/locale/pl-PL.ts
Normal 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;
|
||||
41
packages/docs-hyper-link-ui/src/locale/pt-BR.ts
Normal file
41
packages/docs-hyper-link-ui/src/locale/pt-BR.ts
Normal 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;
|
||||
41
packages/docs-hyper-link-ui/src/locale/ru-RU.ts
Normal file
41
packages/docs-hyper-link-ui/src/locale/ru-RU.ts
Normal 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;
|
||||
41
packages/docs-hyper-link-ui/src/locale/sk-SK.ts
Normal file
41
packages/docs-hyper-link-ui/src/locale/sk-SK.ts
Normal 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;
|
||||
20
packages/docs-hyper-link-ui/src/locale/types.ts
Normal file
20
packages/docs-hyper-link-ui/src/locale/types.ts
Normal 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>;
|
||||
41
packages/docs-hyper-link-ui/src/locale/vi-VN.ts
Normal file
41
packages/docs-hyper-link-ui/src/locale/vi-VN.ts
Normal 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;
|
||||
42
packages/docs-hyper-link-ui/src/locale/zh-CN.ts
Normal file
42
packages/docs-hyper-link-ui/src/locale/zh-CN.ts
Normal 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;
|
||||
41
packages/docs-hyper-link-ui/src/locale/zh-HK.ts
Normal file
41
packages/docs-hyper-link-ui/src/locale/zh-HK.ts
Normal 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;
|
||||
41
packages/docs-hyper-link-ui/src/locale/zh-TW.ts
Normal file
41
packages/docs-hyper-link-ui/src/locale/zh-TW.ts
Normal 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;
|
||||
53
packages/docs-hyper-link-ui/src/menu/menu.ts
Normal file
53
packages/docs-hyper-link-ui/src/menu/menu.ts
Normal 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,
|
||||
};
|
||||
62
packages/docs-hyper-link-ui/src/menu/schema.ts
Normal file
62
packages/docs-hyper-link-ui/src/menu/schema.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
90
packages/docs-hyper-link-ui/src/mobile-plugin.ts
Normal file
90
packages/docs-hyper-link-ui/src/mobile-plugin.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
98
packages/docs-hyper-link-ui/src/plugin.ts
Normal file
98
packages/docs-hyper-link-ui/src/plugin.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
17
packages/docs-hyper-link-ui/src/types/const/index.ts
Normal file
17
packages/docs-hyper-link-ui/src/types/const/index.ts
Normal 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';
|
||||
103
packages/docs-hyper-link-ui/src/views/DocHyperLinkEdit.tsx
Normal file
103
packages/docs-hyper-link-ui/src/views/DocHyperLinkEdit.tsx
Normal 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';
|
||||
166
packages/docs-hyper-link-ui/src/views/DocLinkPopup.tsx
Normal file
166
packages/docs-hyper-link-ui/src/views/DocLinkPopup.tsx
Normal 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';
|
||||
103
packages/docs-hyper-link-ui/src/views/MobileDocHyperLinkEdit.tsx
Normal file
103
packages/docs-hyper-link-ui/src/views/MobileDocHyperLinkEdit.tsx
Normal 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';
|
||||
115
packages/docs-hyper-link-ui/src/views/MobileDocLinkPopup.tsx
Normal file
115
packages/docs-hyper-link-ui/src/views/MobileDocLinkPopup.tsx
Normal 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';
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
27
packages/docs-hyper-link-ui/tailwind.config.ts
Normal file
27
packages/docs-hyper-link-ui/tailwind.config.ts
Normal 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;
|
||||
8
packages/docs-hyper-link-ui/tsconfig.json
Normal file
8
packages/docs-hyper-link-ui/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@univerjs-infra/shared/tsconfigs/base",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
18
packages/docs-hyper-link-ui/tsconfig.node.json
Normal file
18
packages/docs-hyper-link-ui/tsconfig.node.json
Normal 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"
|
||||
]
|
||||
}
|
||||
3
packages/docs-hyper-link-ui/vitest.config.ts
Normal file
3
packages/docs-hyper-link-ui/vitest.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import createConfig from '@univerjs-infra/shared/vitest';
|
||||
|
||||
export default createConfig();
|
||||
Reference in New Issue
Block a user